phpdot/validator

Strict, type-safe validation with structured error codes for the PHPdot ecosystem.

Maintainers

Package info

github.com/phpdot/validator

pkg:composer/phpdot/validator

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-05-02 14:35 UTC

This package is auto-updated.

Last update: 2026-05-02 14:37:25 UTC


README

Strict, type-safe validation for the PHPdot ecosystem. Returns structured ErrorBag instances from phpdot/error — same shape your controllers, services, and APIs already use.

No DSL strings. No global state. Every failing rule must declare its ErrorCodeInterface — the developer's enum carries the user-facing message ("Username is required."), the validator just decides whether the value passed.

Install

composer require phpdot/validator
Requirement Version
PHP >= 8.3
phpdot/error ^1.2

Quick Start

use PHPdot\Validator\Rule\Email;
use PHPdot\Validator\Rule\Min;
use PHPdot\Validator\Rule\Required;
use PHPdot\Validator\ValidatorFactory;

// 1. Define your domain error codes (normal phpdot/error usage)
enum UserErrorCode: string implements ErrorCodeInterface
{
    case UsernameRequired = '02010001';
    case UsernameTooShort = '02010002';
    case EmailRequired    = '02010003';
    case EmailInvalid     = '02010004';

    // getCode, getMessage, getDescription, getType=VALIDATION, getHttpStatus=422, getDetails — see phpdot/error
}

// 2. Inject the factory, create a fresh Validator per validation session
$validator = (new ValidatorFactory())->create();

$errors = $validator->validate($request->all(), [
    'username' => [
        (new Required())->withError(UserErrorCode::UsernameRequired),
        (new Min(3))->withError(UserErrorCode::UsernameTooShort),
    ],
    'email' => [
        (new Required())->withError(UserErrorCode::EmailRequired),
        (new Email())->withError(UserErrorCode::EmailInvalid),
    ],
]);

// 3. Use the bag — same API everywhere in your app
if ($errors->hasErrors()) {
    return $response->json($errors->toArray(), $errors->getHttpStatus());
}

Auto-wired with DI: inject ValidatorFactory, call create() per validation. The factory threads any registered MessageTranslatorInterface into the bag automatically — your services never import the translator interface.

public function __construct(private readonly ValidatorFactory $validators) {}

$bag = $this->validators->create()->validate($input, $rules);

Multi-payload accumulation — reuse the same validator across multiple validate() calls; errors accumulate into one bag:

$v = $this->validators->create();
$v->validate($userInput,    $userRules);
$v->validate($paymentInput, $paymentRules);
$bag = $v->errors();   // both payloads' errors combined

Why This Validator

No DSL strings. Rules are typed instances. PHPStan checks them. IDEs autocomplete. Refactor-safe.
Explicit error codes. Every failing rule must call ->withError($code). Forgetting throws MissingErrorCodeException.
Reuses phpdot/error. Output is ErrorBag — the same shape used by controllers, services, exception handlers. One JSON shape across the app.
Cross-field rules. Every rule receives a ValidationContext with the entire payload. After:start_date, DaysBetween, Confirmed, Same, RequiredIf all work natively.
Custom rules are first-class. Extend Rule and you get withError() for free. No registration, no factories.
Closure rules. Rule::closure(fn($v, $ctx) => bool)->withError($code) for inline business logic.
Strict by design. phpdot/validator rejects ambiguity — no fallback codes, no string-to-rule magic, no positional surprises.

Architecture

graph LR
    subgraph "Construction"
        VF[ValidatorFactory]
        BF[ErrorBagFactory]
        V[Validator]
        BAG[ErrorBag]
        VF -->|create| V
        VF -->|delegates to| BF
        BF -->|create| BAG
        V -->|holds| BAG
    end

    TRANS[MessageTranslatorInterface]
    BF -->|optional| TRANS
    BAG -->|optional| TRANS

    subgraph "Per validate() call"
        DATA[Payload data]
        RULES[Rule chain<br/>per field]
        CTX[ValidationContext]
        DATA -.->|input| V
        RULES -.->|input| V
        V -->|builds| CTX
    end

    V -->|adds entries| BAG
    BAG --> RESP[Controller response<br/>toArray / forContext]

    style VF fill:#2d3748,color:#fff
    style V fill:#2d3748,color:#fff
    style BAG fill:#4a5568,color:#fff
    style BF fill:#4a5568,color:#fff
    style TRANS fill:#718096,color:#fff
    style CTX fill:#718096,color:#fff
Loading

Validate flow

flowchart TD
    A["validate(data, rules)"] --> B[Per field]
    B --> C[Build ValidationContext]
    C --> D[Next rule in chain]
    D --> E{Sometimes &&<br/>field absent?}
    E -->|yes| Z[Skip rest of chain]
    E -->|no| F{Nullable &&<br/>value null?}
    F -->|yes| Z
    F -->|no| G["rule->passes(value, ctx)"]
    G -->|true| D
    G -->|false| H{rule has<br/>error code?}
    H -->|no| EX[Throw MissingErrorCodeException]
    H -->|yes| I["bag->add(code, field, params)"]
    I --> D
    Z --> J[Return held bag]

    style A fill:#2d3748,color:#fff
    style J fill:#276749,color:#fff
    style EX fill:#9b2c2c,color:#fff
Loading
src/
├── Contract/
│   └── RuleInterface.php
├── Rule/
│   ├── Required.php   RequiredIf.php   RequiredUnless.php
│   ├── RequiredWith.php   RequiredWithout.php   Filled.php
│   ├── Present.php   Nullable.php   Sometimes.php   Bail.php
│   ├── Prohibited.php   ProhibitedIf.php   ProhibitedUnless.php
│   ├── Missing.php   MissingIf.php   MissingUnless.php
│   ├── StringType.php   Integer.php   Numeric.php
│   ├── Boolean.php   ArrayType.php   Json.php
│   ├── Min.php   Max.php   Between.php   Size.php
│   ├── Email.php   Url.php   Uuid.php
│   ├── Ip.php   Ipv4.php   Ipv6.php
│   ├── Regex.php   Alpha.php   AlphaNum.php   AlphaDash.php   Slug.php
│   ├── Lowercase.php   Uppercase.php   Ascii.php
│   ├── Digits.php   DigitsBetween.php   Enum.php
│   ├── Distinct.php
│   ├── Same.php   Different.php   Confirmed.php
│   ├── Gt.php   Gte.php   Lt.php   Lte.php
│   ├── In.php   NotIn.php   StartsWith.php   EndsWith.php
│   ├── Date.php   DateFormat.php   DateEquals.php
│   ├── After.php   AfterOrEqual.php   Before.php   BeforeOrEqual.php
│   ├── DateBetween.php   DaysBetween.php
│   ├── Unique.php   Exists.php
│   └── ClosureRule.php
├── Exception/
│   ├── ValidatorException.php
│   ├── MissingErrorCodeException.php
│   └── InvalidRuleException.php
├── Rule.php                   abstract base — provides withError()
├── ValidationContext.php
├── Validator.php              holds an ErrorBag, accumulates entries
└── ValidatorFactory.php       produces fresh Validator instances

Rule Reference

Presence

Rule Behavior
Required Field must be present and non-empty. Empty = null, [], or a whitespace-only string. 0, '0', false are NOT empty.
RequiredIf($otherField, $values) Required when another field equals one of the values.
RequiredUnless($otherField, $values) Required UNLESS another field equals one of the values.
RequiredWith(...$fields) Required when ANY of the listed fields are present.
RequiredWithout(...$fields) Required when ANY of the listed fields are missing.
Filled If present, must be non-empty. (Optional but, if you send it, send something.)
Present The key must be in the payload — value can be empty.
Prohibited Field must be absent or empty. Mirror of Required.
ProhibitedIf($otherField, $values) Prohibited when another field equals one of the values.
ProhibitedUnless($otherField, $values) Prohibited UNLESS another field equals one of the values.
Missing Field key must NOT be in the payload at all (stricter than Prohibited).
MissingIf($otherField, $values) Field must be absent when another field equals one of the values.
MissingUnless($otherField, $values) Field must be absent UNLESS another field equals one of the values.
Nullable Marker — skips the rest of the chain when the value is null.
Sometimes Marker — skips the rest of the chain when the field is absent.
Bail Marker — when present anywhere in a field's chain, the chain stops at the first failure. Position-independent.

Type

Rule Behavior
StringType is_string()
Integer int, or numeric string with no fractional part
Numeric int, float, or numeric string
Boolean true/false, 0/1, '0'/'1'/'true'/'false'
ArrayType is_array()
Json string containing valid JSON (json_validate())

Size

Rule Works on
Min($n) / Max($n) / Between($min, $max) / Size($n) Numeric value, string length (mb), or array count

Format

Rule Behavior
Email FILTER_VALIDATE_EMAIL
Url FILTER_VALIDATE_URL
Uuid hyphenated 8-4-4-4-12, any version
Ip / Ipv4 / Ipv6 FILTER_VALIDATE_IP
Regex($pattern) PCRE match
Alpha Unicode letters only
AlphaNum Unicode letters and digits
AlphaDash Unicode letters, digits, -, _
Slug ^[a-z0-9]+(-[a-z0-9]+)*$
Lowercase String with no uppercase letters
Uppercase String with no lowercase letters
Ascii String containing only 7-bit ASCII characters
Digits($n) Numeric string with exactly $n digits — leading zeros preserved
DigitsBetween($min, $max) Numeric string whose digit count falls in the inclusive range
Enum($enumClass) Value must be a backing value of the given backed enum (class-string<\BackedEnum>)

Choice

Rule Behavior
In(...$values) Strict in_array()
NotIn(...$values) Strict !in_array()
StartsWith(...$prefixes) Any prefix matches
EndsWith(...$suffixes) Any suffix matches
Distinct Array contains no duplicate values (strict comparison)

Comparison (cross-field aware)

Rule Behavior
Same($otherField) Strict equality with another field
Different($otherField) Strict inequality with another field
Confirmed Equals {field}_confirmation
Gt, Gte, Lt, Lte($bound) Numeric/size comparison. Bound is a literal OR field name.

Date

Rule Behavior
Date Parseable by strtotime or DateTimeInterface instance
DateFormat($format) Strict format match (no truncation)
DateEquals($reference) Same date as reference (literal or field name)
After($reference) / AfterOrEqual / Before / BeforeOrEqual Cross-field date comparison
DateBetween($start, $end) Inclusive date range; bounds can be literals or field names
DaysBetween($startField, $endField, max: $days) Days between two date fields must not exceed $days

Database

Rule Behavior
Unique(Closure $resolver) Resolver returns true if the value already exists; rule passes when it doesn't
Exists(Closure $resolver) Resolver returns true if the value exists; rule passes when it does

The resolver signature is fn(mixed $value, ValidationContext $ctx): bool. No DB coupling — pass any callable: a repository method, a closure that hits Mongo, an HTTP lookup, anything.

Closure

Rule::closure(fn(mixed $value, ValidationContext $ctx): bool => /* ... */)
    ->withError(UserErrorCode::SomeCondition);

Working Examples

Signup Form

$errors = $validator->validate($request->all(), [
    'username' => [
        (new Required())->withError(UserErrorCode::UsernameRequired),
        (new StringType())->withError(UserErrorCode::UsernameRequired),
        (new Min(3))->withError(UserErrorCode::UsernameTooShort),
        (new Max(50))->withError(UserErrorCode::UsernameTooLong),
        (new Unique(fn (mixed $v): bool => $repo->existsByUsername((string) $v)))
            ->withError(UserErrorCode::UsernameTaken),
    ],
    'email' => [
        (new Required())->withError(UserErrorCode::EmailRequired),
        (new Email())->withError(UserErrorCode::EmailInvalid),
    ],
    'role' => [
        (new Required())->withError(UserErrorCode::RoleInvalid),
        (new In('admin', 'editor', 'viewer'))->withError(UserErrorCode::RoleInvalid),
    ],
    'password' => [
        (new Required())->withError(UserErrorCode::PasswordWeak),
        (new Min(8))->withError(UserErrorCode::PasswordWeak),
        (new Regex('/[a-zA-Z]/'))->withError(UserErrorCode::PasswordWeak),
        (new Regex('/[0-9]/'))->withError(UserErrorCode::PasswordWeak),
        (new Confirmed())->withError(UserErrorCode::PasswordMismatch),
    ],
]);

Date Range — End ≥ Start, Span ≤ 30 Days

$errors = $validator->validate($request->all(), [
    'start_date' => [
        (new Required())->withError(BookingErrorCode::StartDateInvalid),
        (new Date())->withError(BookingErrorCode::StartDateInvalid),
    ],
    'end_date' => [
        (new Required())->withError(BookingErrorCode::EndDateBeforeStart),
        (new Date())->withError(BookingErrorCode::EndDateBeforeStart),
        (new AfterOrEqual('start_date'))->withError(BookingErrorCode::EndDateBeforeStart),
        (new DaysBetween('start_date', 'end_date', max: 30))
            ->withError(BookingErrorCode::DateRangeTooLong),
    ],
]);

Custom Rule

Extend Rule and you're done — withError() is inherited.

use PHPdot\Validator\Rule;
use PHPdot\Validator\ValidationContext;

final class UsernameAvailable extends Rule
{
    public function __construct(
        private readonly UserRepositoryInterface $users,
    ) {}

    public function passes(mixed $value, ValidationContext $context): bool
    {
        return !$this->users->existsByUsername((string) $value);
    }
}

// Used identically to built-ins:
'username' => [
    (new UsernameAvailable($repo))->withError(UserErrorCode::UsernameTaken),
],

Closure for One-Off Logic

'end_date' => [
    (new Required())->withError(BookingErrorCode::EndDateInvalid),
    (new Date())->withError(BookingErrorCode::EndDateInvalid),
    Rule::closure(function (mixed $value, ValidationContext $ctx): bool {
        $weekday = (int) date('N', (int) strtotime((string) $value));
        return $weekday < 6;   // Mon-Fri only
    })->withError(BookingErrorCode::WeekendNotAllowed),
],

Error Output

Validator::validate() returns phpdot/error's ErrorBag. Use it directly:

$errors->hasErrors();                        // bool
$errors->forContext('email');                // list<ErrorEntry> for one field
$errors->ofType(ErrorType::VALIDATION);      // list<ErrorEntry> by category
$errors->getHttpStatus();                    // 422 for validation errors
$errors->toArray();                          // list of arrays — JSON / flash safe
$errors->codes();                            // list<string> — unique error codes

JSON API Response

if ($errors->hasErrors()) {
    return $response->json(
        ['errors' => $errors->toArray()],
        $errors->getHttpStatus(),
    );
}

Form Re-render with phpdot/session + phpdot/template

if ($errors->hasErrors()) {
    $session->flash('errors', $errors);
    $session->flash('old', $request->all());
    return $response->redirect('/signup');
}

In Twig:

{% set fieldErrors = errors.forContext('email') %}
{% if fieldErrors|length %}
    <span class="error">{{ fieldErrors[0].message }}</span>
{% endif %}

Strict by Design

A failing rule that has not had ->withError($code) called on it throws MissingErrorCodeException:

$validator->validate(['email' => 'bad'], [
    'email' => [new Email()],   // no withError() — will throw
]);
// MissingErrorCodeException: Rule PHPdot\Validator\Rule\Email failed for
// field "email" without a bound error code. Call ->withError($code) on the
// rule instance.

This is intentional. Generic "the email field is invalid" defeats the purpose of structured errors. Be explicit, get a specific message ("Please enter a valid email address.") tied to a stable code (02010005) the frontend can branch on.

DI Wiring

Validator is stateless and trivially singletonable:

Validator::class => singleton(Validator::class),

In a controller:

use PHPdot\Validator\Validator;

final class SignupController
{
    public function __construct(
        private readonly Validator $validator,
        private readonly UserRepositoryInterface $users,
        private readonly ResponseFactory $response,
    ) {}

    public function store(RequestInterface $request): ResponseInterface
    {
        $errors = $this->validator->validate($request->all(), $this->rules());

        if ($errors->hasErrors()) {
            return $this->response->json(
                ['errors' => $errors->toArray()],
                $errors->getHttpStatus(),
            );
        }

        $this->users->create($request->all());

        return $this->response->json(['ok' => true], 201);
    }

    private function rules(): array
    {
        return [
            'username' => [
                (new Required())->withError(UserErrorCode::UsernameRequired),
                (new Unique(fn (mixed $v): bool => $this->users->existsByUsername((string) $v)))
                    ->withError(UserErrorCode::UsernameTaken),
            ],
            // ...
        ];
    }
}

Development

composer test        # 131 tests
composer analyse     # PHPStan level 10 + strict rules
composer cs-fix      # Apply code style
composer cs-check    # Verify code style
composer check       # All three

License

MIT