Skip to content

Validation

Object graph validation with 21 built-in validators, fluent field configuration and automatic traversal.

Introduction

Validation in PHP often comes down to one of two extremes: either a framework monolith with annotations, reflection magic and hundreds of classes, or handwritten if chains in every use case that break with the first refactoring.

jardissupport/validation takes a third path. The package validates entire object graphs automatically (nested objects, arrays of entities, circular references) and needs neither annotations nor attributes for this:

  • Automatic object graph traversal — register a validator for Order and one for OrderLine, and validation finds all nested objects by itself
  • 21 built-in validators — Email, UUID, IBAN, credit card, phone, IP, URL, JSON, regex and more
  • Fluent field configuration->field('email')->validates(Email::class, Email::strict()) instead of cryptic options arrays
  • Break mode — guard validation: when a precondition fails, the rest is skipped
  • Partial updates — skip fields on create, validate on update, without two separate validators
  • Circular referencesspl_object_id()-based detection prevents infinite loops
  • Static helpers as option factoriesEmail::strict(), Range::between(1, 100), Uuid::v4() instead of guessing option keys

Installation

bash
composer require jardissupport/validation

GitHub: jardisSupport/validation

Basic Usage

Field Validation with Fluent API

php
use JardisSupport\Validation\CompositeFieldValidator;
use JardisSupport\Validation\Validator\Email;
use JardisSupport\Validation\Validator\Length;
use JardisSupport\Validation\Validator\NotBlank;
use JardisSupport\Validation\Validator\Range;
use JardisSupport\Validation\Validator\Uuid;

$validator = new CompositeFieldValidator();
$validator
    ->field('id')
        ->validates(Uuid::class, Uuid::v4())
    ->field('email')
        ->validates(Email::class, Email::strict())
    ->field('username')
        ->validates(NotBlank::class)
        ->validates(Length::class, Length::between(3, 20))
    ->field('age')
        ->validates(Range::class, Range::between(18, 120))
    ->end();

$result = $validator->validate($user);

if (!$result->isValid()) {
    $result->getErrors();              // ['email' => ['Invalid email'], 'age' => ['...'], ...]
    $result->getFieldErrors('email');   // ['Invalid email address']
    $result->getFirstError('email');    // 'Invalid email address'
    $result->getErrorCount();           // 2
}

Object Graph Validation

php
use JardisSupport\Validation\ObjectValidator;
use JardisSupport\Validation\ValidatorRegistry;

$orderValidator = new CompositeFieldValidator();
$orderValidator
    ->field('customerId')->validates(Uuid::class, Uuid::v4())
    ->field('totalAmount')->validates(Positive::class);

$lineValidator = new CompositeFieldValidator();
$lineValidator
    ->field('productId')->validates(Uuid::class, Uuid::v4())
    ->field('quantity')->validates(Range::class, Range::min(1));

$registry = new ValidatorRegistry();
$registry
    ->register(Order::class, $orderValidator)
    ->register(OrderLine::class, $lineValidator);

$validator = new ObjectValidator($registry);
$result = $validator->validate($order);

// Errors grouped by short class name:
// ['order' => ['totalAmount' => ['...']], 'orderLine' => ['quantity' => ['...']]]

The ObjectValidator automatically traverses all public and protected properties of the object, finds nested objects and arrays, and validates everything for which a validator is registered in the registry.

Field Value Resolution

The CompositeFieldValidator requires no specific interface on domain objects. It finds values via a 5-stage resolution chain:

PriorityPatternExample for field email
1get{Field}()$obj->getEmail()
2is{Field}()$obj->isEmail()
3has{Field}()$obj->hasEmail()
4{Field}()$obj->Email()
5Property reflection$obj->email (also protected)

Break Mode — Guard Validation

Sometimes validation should stop immediately when a precondition is not met. breaksOn() registers a guard validator; if it fails, an empty (valid) result is returned:

php
$validator = new CompositeFieldValidator();
$validator
    ->field('id')
        ->breaksOn(Uuid::class, Uuid::v4())   // Guard: invalid ID → abort
    ->field('email')
        ->validates(Email::class, Email::strict())
    ->field('amount')
        ->validates(Positive::class);

Use case: In a CQRS command handler: "If the ID is not valid, don't bother checking the business rules."

Break vs. Normal

breaksOn() aborts on any error and returns a valid result (no error messages). validates() collects all errors and returns them together. Both can be combined on the same field.

Partial Updates

A common problem: create requires all fields, update only the changed ones. Instead of maintaining two validators:

php
$validator = new CompositeFieldValidator();
$validator
    ->field('id')->validates(Uuid::class, Uuid::v4())
    ->field('email')->validates(Email::class, Email::strict())
    ->field('password')->validates(Length::class, Length::min(8))
    ->excludeFields(['password']);

// Create (id = null): password is NOT validated
$validator->validate($newUser);

// Update (id = 'abc-123'): password is validated
$validator->validate($existingUser);

excludeFields() skips the named fields only when the identity field (id by default) is null. A different field can be used as indicator via withIdentityField('uuid').

The 21 Validators

All validators implement ValueValidatorInterface and return null on success or an error text on violation. null values pass every validator: use NotBlank or NotEmpty for required field checks.

Required Field Validators

ValidatorChecksStatic helpers
NotBlankValue is not nullNotBlank::required()
NotEmptyValue is not null, not empty, no whitespaceNotEmpty::trimmed(), NotEmpty::strict()

String Validators

ValidatorChecksStatic helpers
LengthCharacter length (min/max/exact)Length::between(3, 20), Length::min(8), Length::max(100), Length::exact(5)
FormatRegex patternFormat::pattern('/^[A-Z]{3}$/'), Format::slug(), Format::hexColor()
AlphanumericOnly a-zA-Z0-9 (+ options)Alphanumeric::withDashes(), withSpaces(), withUnderscores()
ContainValue is in allowed listContain::oneOf(['active', 'inactive'])
EqualsEquality with expected valueEquals::strict($val), Equals::loose($val)

Format Validators

ValidatorChecksStatic helpers
EmailEmail address (optional DNS + strict)Email::basic(), Email::withDnsCheck(), Email::strict()
UuidRFC 4122 UUID (optional version)Uuid::any(), Uuid::v4(), Uuid::v1()
UrlURL (XSS protection, protocol filter)Url::httpsOnly(), Url::noLocalhost(), Url::secure()
IpIPv4/IPv6 (optional private/reserved)Ip::v4(), Ip::v6(), Ip::noPrivate(), Ip::publicV4()
DateTimeDate/time format + rangeDateTime::iso8601(), DateTime::dateOnly(), DateTime::between(min, max, fmt)
JsonJSON syntax + typeJson::object(), Json::array(), Json::maxDepth(5)

Numeric Validators

ValidatorChecksStatic helpers
RangeNumeric rangeRange::between(1, 100), Range::min(0), Range::max(999)
PositivePositive value (> 0 or >= 0)Positive::strict(), Positive::allowZero()

Collection Validators

ValidatorChecksStatic helpers
CountArray/Countable lengthCount::between(1, 10), Count::min(1), Count::exact(3)
UniqueItemsNo duplicates in arrayUniqueItems::strict(), UniqueItems::loose()

Special Validators

ValidatorChecksStatic helpers
CreditCardLuhn algorithm + card typeCreditCard::visa(), mastercard(), amex(), discover()
IbanFormat + mod-97 checksum (70+ countries)Iban::sepa(), Iban::forCountry('DE')
PhoneNumberFormat + country-specific (10 countries)PhoneNumber::german(), us(), international()
CallbackCustom logic via closureDirect instantiation: new Callback(fn($v) => ...)

Callback Validator

For project-specific validation logic that doesn't justify its own validator:

php
use JardisSupport\Validation\Validator\Callback;

$validator = new Callback(function (mixed $value): ?string {
    if (!is_array($value) || count($value) < 2) {
        return 'At least 2 elements required';
    }
    return null;  // null = valid
});

$validator->validateValue([1, 2]);  // null (valid)
$validator->validateValue([1]);     // 'At least 2 elements required'

Callback in CompositeFieldValidator

Callback is passed as an instance, not as a class string. To use it in CompositeFieldValidator, register the handler directly.

ValidationResult

The result of every validation is an immutable ValidationResult:

php
$result = $validator->validate($object);

$result->isValid();                     // bool
$result->getErrors();                   // ['field' => ['error1', 'error2'], ...]
$result->getFieldErrors('email');       // ['Invalid email address']
$result->getFirstError('email');        // 'Invalid email address'
$result->hasFieldError('email');        // true
$result->getAllFieldsWithErrors();      // ['email', 'age']
$result->getErrorCount();              // Number of fields with errors

Circular References and Depth Limit

The ObjectValidator detects circular references via spl_object_id() and skips already-visited objects. Additionally there is a configurable depth limit:

php
use JardisSupport\Validation\Internal\ValidationContext;

// Default: maxDepth = 100
$validator = new ObjectValidator($registry);

// Custom limit
$context = new ValidationContext(maxDepth: 10);
$validator = new ObjectValidator($registry, $context);
// Throws \RuntimeException when exceeded

Architecture

Under the hood the package follows the Closure-Orchestrator-Pattern in a three-layer structure:

ObjectValidator                        ← Orchestrator (graph traversal)
├── ValidatorRegistry                  ← Class → validator mapping
│   └── CompositeFieldValidator        ← Field validation (implements ValidatorInterface)
│       ├── FieldBuilder               ← Fluent API
│       └── Validator/                 ← 21 value validators
│           ├── Email
│           ├── Uuid
│           ├── Range
│           └── ...
└── ValidationContext                  ← Visited objects + depth counter

Directory Structure

src/
├── ObjectValidator.php              ← Orchestrator (graph traversal)
├── CompositeFieldValidator.php      ← Field rule composition
├── ValidatorRegistry.php            ← Class → validator mapping
├── Internal/
│   ├── FieldBuilder.php             ← Fluent builder
│   └── ValidationContext.php        ← Traversal state
└── Validator/
    ├── Alphanumeric.php
    ├── Callback.php
    ├── Contain.php
    ├── Count.php
    ├── CreditCard.php
    ├── DateTime.php
    ├── Email.php
    ├── Equals.php
    ├── Format.php
    ├── Iban.php
    ├── Ip.php
    ├── Json.php
    ├── Length.php
    ├── NotBlank.php
    ├── NotEmpty.php
    ├── PhoneNumber.php
    ├── Positive.php
    ├── Range.php
    ├── UniqueItems.php
    ├── Url.php
    └── Uuid.php

API Reference

ObjectValidator

MethodSignatureDescription
__construct__construct(ValidatorRegistry $registry, ?ValidationContext $context = null)Registry and optional context
validatevalidate(object $object): ValidationResultValidate object graph

CompositeFieldValidator

MethodSignatureDescription
fieldfield(string $name): FieldBuilderStart fluent field configuration
excludeFieldsexcludeFields(array $fields): selfSkip fields on create
withIdentityFieldwithIdentityField(string $name): selfChange identity field (default: id)
validatevalidate(object $data): ValidationResultValidate object

FieldBuilder

MethodSignatureDescription
validatesvalidates(string $validatorClass, array $options = []): selfAdd normal validator
breaksOnbreaksOn(string $validatorClass, array $options = []): selfAdd guard validator
fieldfield(string $name): FieldBuilderConfigure next field
endend(): CompositeFieldValidatorFinish builder

ValidatorRegistry

MethodSignatureDescription
registerregister(string $className, ValidatorInterface $validator): selfRegister validator
getValidatorgetValidator(object $object): ?ValidatorInterfaceFind validator for object

Complete Example

An e-commerce scenario with nested validation, guard mode and partial updates:

php
use JardisSupport\Validation\CompositeFieldValidator;
use JardisSupport\Validation\ObjectValidator;
use JardisSupport\Validation\ValidatorRegistry;
use JardisSupport\Validation\Validator\Count;
use JardisSupport\Validation\Validator\Email;
use JardisSupport\Validation\Validator\Iban;
use JardisSupport\Validation\Validator\Length;
use JardisSupport\Validation\Validator\NotBlank;
use JardisSupport\Validation\Validator\Positive;
use JardisSupport\Validation\Validator\Range;
use JardisSupport\Validation\Validator\Uuid;

// Order validator with guard and partial update
$orderValidator = new CompositeFieldValidator();
$orderValidator
    ->field('id')
        ->breaksOn(Uuid::class, Uuid::v4())       // Guard: invalid ID → abort
    ->field('customerId')
        ->validates(NotBlank::class)
        ->validates(Uuid::class, Uuid::v4())
    ->field('customerEmail')
        ->validates(Email::class, Email::strict())
    ->field('shippingAddress')
        ->validates(NotBlank::class)
        ->validates(Length::class, Length::between(10, 500))
    ->field('totalAmount')
        ->validates(Positive::class, Positive::strict())
    ->field('lines')
        ->validates(Count::class, Count::min(1))
    ->excludeFields(['shippingAddress']);           // Optional on create

// OrderLine validator
$lineValidator = new CompositeFieldValidator();
$lineValidator
    ->field('productId')
        ->validates(Uuid::class, Uuid::v4())
    ->field('productName')
        ->validates(NotBlank::class)
        ->validates(Length::class, Length::max(200))
    ->field('quantity')
        ->validates(Range::class, Range::between(1, 9999))
    ->field('unitPrice')
        ->validates(Positive::class);

// Payment validator
$paymentValidator = new CompositeFieldValidator();
$paymentValidator
    ->field('iban')
        ->validates(Iban::class, Iban::sepa())
    ->field('amount')
        ->validates(Positive::class, Positive::strict());

// Registry: class → validator
$registry = new ValidatorRegistry();
$registry
    ->register(Order::class, $orderValidator)
    ->register(OrderLine::class, $lineValidator)
    ->register(PaymentInfo::class, $paymentValidator);

// Validation — automatically traverses Order → OrderLines → PaymentInfo
$validator = new ObjectValidator($registry);
$result = $validator->validate($order);

if (!$result->isValid()) {
    foreach ($result->getAllFieldsWithErrors() as $field) {
        echo "{$field}: " . $result->getFirstError($field) . "\n";
    }
}