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
Orderand one forOrderLine, 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 references —
spl_object_id()-based detection prevents infinite loops - Static helpers as option factories —
Email::strict(),Range::between(1, 100),Uuid::v4()instead of guessing option keys
Installation
composer require jardissupport/validationGitHub: jardisSupport/validation
Basic Usage
Field Validation with Fluent API
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
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:
| Priority | Pattern | Example for field email |
|---|---|---|
| 1 | get{Field}() | $obj->getEmail() |
| 2 | is{Field}() | $obj->isEmail() |
| 3 | has{Field}() | $obj->hasEmail() |
| 4 | {Field}() | $obj->Email() |
| 5 | Property 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:
$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:
$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
| Validator | Checks | Static helpers |
|---|---|---|
NotBlank | Value is not null | NotBlank::required() |
NotEmpty | Value is not null, not empty, no whitespace | NotEmpty::trimmed(), NotEmpty::strict() |
String Validators
| Validator | Checks | Static helpers |
|---|---|---|
Length | Character length (min/max/exact) | Length::between(3, 20), Length::min(8), Length::max(100), Length::exact(5) |
Format | Regex pattern | Format::pattern('/^[A-Z]{3}$/'), Format::slug(), Format::hexColor() |
Alphanumeric | Only a-zA-Z0-9 (+ options) | Alphanumeric::withDashes(), withSpaces(), withUnderscores() |
Contain | Value is in allowed list | Contain::oneOf(['active', 'inactive']) |
Equals | Equality with expected value | Equals::strict($val), Equals::loose($val) |
Format Validators
| Validator | Checks | Static helpers |
|---|---|---|
Email | Email address (optional DNS + strict) | Email::basic(), Email::withDnsCheck(), Email::strict() |
Uuid | RFC 4122 UUID (optional version) | Uuid::any(), Uuid::v4(), Uuid::v1() |
Url | URL (XSS protection, protocol filter) | Url::httpsOnly(), Url::noLocalhost(), Url::secure() |
Ip | IPv4/IPv6 (optional private/reserved) | Ip::v4(), Ip::v6(), Ip::noPrivate(), Ip::publicV4() |
DateTime | Date/time format + range | DateTime::iso8601(), DateTime::dateOnly(), DateTime::between(min, max, fmt) |
Json | JSON syntax + type | Json::object(), Json::array(), Json::maxDepth(5) |
Numeric Validators
| Validator | Checks | Static helpers |
|---|---|---|
Range | Numeric range | Range::between(1, 100), Range::min(0), Range::max(999) |
Positive | Positive value (> 0 or >= 0) | Positive::strict(), Positive::allowZero() |
Collection Validators
| Validator | Checks | Static helpers |
|---|---|---|
Count | Array/Countable length | Count::between(1, 10), Count::min(1), Count::exact(3) |
UniqueItems | No duplicates in array | UniqueItems::strict(), UniqueItems::loose() |
Special Validators
| Validator | Checks | Static helpers |
|---|---|---|
CreditCard | Luhn algorithm + card type | CreditCard::visa(), mastercard(), amex(), discover() |
Iban | Format + mod-97 checksum (70+ countries) | Iban::sepa(), Iban::forCountry('DE') |
PhoneNumber | Format + country-specific (10 countries) | PhoneNumber::german(), us(), international() |
Callback | Custom logic via closure | Direct instantiation: new Callback(fn($v) => ...) |
Callback Validator
For project-specific validation logic that doesn't justify its own validator:
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:
$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 errorsCircular 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:
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 exceededArchitecture
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 counterDirectory 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.phpAPI Reference
ObjectValidator
| Method | Signature | Description |
|---|---|---|
__construct | __construct(ValidatorRegistry $registry, ?ValidationContext $context = null) | Registry and optional context |
validate | validate(object $object): ValidationResult | Validate object graph |
CompositeFieldValidator
| Method | Signature | Description |
|---|---|---|
field | field(string $name): FieldBuilder | Start fluent field configuration |
excludeFields | excludeFields(array $fields): self | Skip fields on create |
withIdentityField | withIdentityField(string $name): self | Change identity field (default: id) |
validate | validate(object $data): ValidationResult | Validate object |
FieldBuilder
| Method | Signature | Description |
|---|---|---|
validates | validates(string $validatorClass, array $options = []): self | Add normal validator |
breaksOn | breaksOn(string $validatorClass, array $options = []): self | Add guard validator |
field | field(string $name): FieldBuilder | Configure next field |
end | end(): CompositeFieldValidator | Finish builder |
ValidatorRegistry
| Method | Signature | Description |
|---|---|---|
register | register(string $className, ValidatorInterface $validator): self | Register validator |
getValidator | getValidator(object $object): ?ValidatorInterface | Find validator for object |
Complete Example
An e-commerce scenario with nested validation, guard mode and partial updates:
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";
}
}