jardiscore / validation
A flexible package to handle validation in a Domain Driven Design way with nested aggregates
Installs: 38
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
pkg:composer/jardiscore/validation
Requires
- php: >=8.2
- jardispsr/validation: ^1.0
Requires (Dev)
- phpstan/phpstan: ^2.0.4
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.11.2
README
Validation that harmonizes with Domain-Driven Design requirements.
Validate complex object graphs without annotations, without interfaces, without magic. Use type-safe, reusable validators with an elegant fluent API β perfect for DDD aggregates, value objects, and nested entities.
Why Jardis Validation?
- π Elegant & Productive β Fluent API with static factory methods makes validation readable and type-safe
- ποΈ Domain-Driven Design Ready β Validates arbitrary objects and complete aggregates without interface constraints
- π Recursive Object Graph Validation β Automatically traverses nested objects and collections
- π‘οΈ 20+ Production-Ready Validators β Email, IBAN, UUID, PhoneNumber, CreditCard, Range, and more
- β‘ Minimal Dependencies β Only PSR interfaces, optimized for performance
- π Circular-Reference-Safe β Protects against infinite loops in cyclic object structures
Installation
composer require jardiscore/validation
Requires PHP >= 8.2
Quickstart
use JardisCore\Validation\{ObjectValidator, ValidatorRegistry, CompositeFieldValidator}; use JardisCore\Validation\Validator\{NotBlank, Email, Range}; // Domain object class User { public function __construct( private ?string $email = null, private ?int $age = null ) {} public function Email(): ?string { return $this->email; } public function Age(): ?int { return $this->age; } } // Define validator with fluent API $userValidator = (new CompositeFieldValidator()) ->field('email')->validates(NotBlank::class) ->validates(Email::class, Email::withDnsCheck()) ->field('age')->validates(NotBlank::class) ->validates(Range::class, Range::between(18, 120)); // Build registry and validate $validator = new ObjectValidator( (new ValidatorRegistry())->register(User::class, $userValidator) ); $result = $validator->validate(new User('invalid', 15)); if (!$result->isValid()) { print_r($result->getErrors()); // ['user' => ['email' => ['Invalid email address'], 'age' => [...]]] }
Example 1: E-Commerce Checkout with Nested Aggregates
Validate a complete Order aggregate with Customer, BillingAddress, and OrderItems in a single pass:
class Order { public function __construct( private ?string $orderId = null, private ?Customer $customer = null, private ?Address $billingAddress = null, private array $items = [] ) {} public function OrderId(): ?string { return $this->orderId; } public function Customer(): ?Customer { return $this->customer; } public function BillingAddress(): ?Address { return $this->billingAddress; } public function Items(): array { return $this->items; } } class Customer { public function __construct( private ?string $email = null, private ?string $phone = null ) {} public function Email(): ?string { return $this->email; } public function Phone(): ?string { return $this->phone; } } class Address { public function __construct( private ?string $street = null, private ?string $zipCode = null, private ?string $iban = null ) {} public function Street(): ?string { return $this->street; } public function ZipCode(): ?string { return $this->zipCode; } public function Iban(): ?string { return $this->iban; } } class OrderItem { public function __construct( private ?string $productId = null, private ?int $quantity = null ) {} public function ProductId(): ?string { return $this->productId; } public function Quantity(): ?int { return $this->quantity; } } use JardisCore\Validation\Validator\{Uuid, PhoneNumber, Iban, Positive, Count, Format}; // Validator setup $orderValidator = (new CompositeFieldValidator()) ->field('orderId')->validates(Uuid::class, Uuid::v4()); $customerValidator = (new CompositeFieldValidator()) ->field('email')->validates(Email::class, Email::strict()) ->field('phone')->validates(PhoneNumber::class, PhoneNumber::german()); $addressValidator = (new CompositeFieldValidator()) ->field('street')->validates(NotBlank::class) ->field('zipCode')->validates(Format::class, Format::pattern('/^\d{5}$/')) ->field('iban')->validates(Iban::class, Iban::sepa()); $itemValidator = (new CompositeFieldValidator()) ->field('productId')->validates(NotBlank::class) ->field('quantity')->validates(Positive::class); $registry = (new ValidatorRegistry()) ->register(Order::class, $orderValidator) ->register(Customer::class, $customerValidator) ->register(Address::class, $addressValidator) ->register(OrderItem::class, $itemValidator); $validator = new ObjectValidator($registry); // Validate entire object graph $order = new Order( orderId: 'invalid-uuid', customer: new Customer(email: 'bad-email', phone: '555'), billingAddress: new Address(street: '', zipCode: '123', iban: 'DE00'), items: [ new OrderItem(productId: null, quantity: -5), new OrderItem(productId: 'SKU-123', quantity: 0) ] ); $result = $validator->validate($order); // Automatically validates: Order β Customer β Address β OrderItem[]
The validation recursively traverses all nested objects and returns a structured error hierarchy.
Example 2: API Updates with Partial Validation
For PATCH requests, you often want to validate only changed fields. With breaksOn() and excludeFields(), you implement early-exit strategies:
class UserUpdateCommand { public function __construct( private ?int $id = null, private ?string $username = null, private ?string $email = null, private ?string $bio = null ) {} public function Id(): ?int { return $this->id; } public function Username(): ?string { return $this->username; } public function Email(): ?string { return $this->email; } public function Bio(): ?string { return $this->bio; } } use JardisCore\Validation\Validator\{Length, Alphanumeric, NotBlank, Email}; $updateValidator = (new CompositeFieldValidator()) // Break validators: Stop on first error ->field('id')->breaksOn(NotBlank::class) // Normal validation with multiple rules ->field('username')->validates(Length::class, Length::between(3, 30)) ->validates(Alphanumeric::class) ->field('email')->validates(Email::class, Email::withDnsCheck()) ->field('bio')->validates(Length::class, Length::max(500)) // Exclude ID for new records without ID ->excludeFields(['id']); $registry = (new ValidatorRegistry())->register(UserUpdateCommand::class, $updateValidator); $validator = new ObjectValidator($registry); // Scenario 1: Update existing user $updateExisting = new UserUpdateCommand( id: 123, username: 'ab', // too short email: 'valid@example.com' ); $result = $validator->validate($updateExisting); // Error: username too short // Scenario 2: New user without ID (ID validation is skipped) $createNew = new UserUpdateCommand( id: null, username: 'validuser', email: 'valid@example.com' ); $result = $validator->validate($createNew); // Success: ID validation is skipped via excludeFields
breaksOn() stops validation immediately on the first error of a break validator β ideal for expensive DB lookups or external API calls that should only run with valid base data.
20+ Production-Ready Validators
| Validator | Description | Factory Methods |
|---|---|---|
| NotBlank | Value must not be null | - |
| NotEmpty | Value must not be empty (null, '', []) | - |
| Range | Numeric value or string length | between(), min(), max() |
| Length | String length | between(), min(), max(), exact() |
| Email with optional DNS check | basic(), withDnsCheck(), strict() |
|
| Url | URL with protocol/localhost validation | - |
| Uuid | UUID with version check | any(), v1(), v3(), v4(), v5() |
| Iban | IBAN with length and checksum validation | sepa(), forCountry() |
| PhoneNumber | Phone number with country code | german(), us(), international() |
| CreditCard | Credit card number (Luhn algorithm) | - |
| Ip | IPv4/IPv6 with private range check | - |
| Json | Valid JSON | - |
| DateTime | Date/time with format parsing | - |
| Format | Regex pattern | - |
| Contain | Value in whitelist | - |
| Equals | Exact value comparison | - |
| Count | Array/collection size | min(), max(), exact() |
| Positive | Positive number | - |
| Alphanumeric | Only alphanumeric characters | - |
| UniqueItems | Array contains no duplicates | - |
| Callback | Custom closure | - |
All validators support static factory methods for type-safe configuration:
->field('email')->validates(Email::class, Email::strict()) ->field('uuid')->validates(Uuid::class, Uuid::v4()) ->field('iban')->validates(Iban::class, Iban::sepa())
Architecture
ObjectValidator
ββ ValidatorRegistry β Maps classes to validators
ββ ValidationContext β Tracks visited objects (circular-ref protection)
ββ CompositeFieldValidator
ββ ValueValidators (20+ built-in)
ββ NotBlank, Email, Range, ...
ββ Singleton instances for performance
- ObjectValidator orchestrates recursive validation and object graph traversal
- CompositeFieldValidator collects field validators with fluent API (
field()βvalidates()) - ValueValidators are stateless and reused as singletons
- ValidationContext protects against circular references and stack overflow
Custom Validators
Implement ValueValidatorInterface for domain-specific rules:
use JardisPsr\Validation\ValueValidatorInterface; class UniqueSku implements ValueValidatorInterface { public function __construct(private ProductRepository $repo) {} public function validateValue(mixed $value, array $options = []): ?string { if ($value === null) return null; return $this->repo->skuExists($value) ? 'SKU already exists' : null; } }
Requirements & License
- PHP >= 8.2
- Dependencies:
jardispsr/validation(PSR interfaces) - License: PolyForm Noncommercial License 1.0.0
Licensing
Non-commercial use is covered by the free PolyForm Noncommercial License and includes:
- Personal use, research, and experimentation
- Use by educational institutions and non-profit organizations
- Open-source projects without commercial application
Commercial use requires a separate commercial license.
For commercial licensing, contact:
- Email: jardiscore@headgent.dev
- Website: https://headgent.dev
Support
Developed by Jardis Development Core