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

1.0.0 2025-12-16 17:30 UTC

This package is auto-updated.

Last update: 2025-12-16 17:31:13 UTC


README

Build Status License PHP Version PSR-4 PSR-12 Coverage

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 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:

Support

Developed by Jardis Development Core