PHPackages                             phpdot/validator - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Validation &amp; Sanitization](/categories/validation)
4. /
5. phpdot/validator

ActiveLibrary[Validation &amp; Sanitization](/categories/validation)

phpdot/validator
================

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

v1.2.0(1mo ago)03MITPHPPHP &gt;=8.3

Since May 2Pushed 1mo agoCompare

[ Source](https://github.com/phpdot/validator)[ Packagist](https://packagist.org/packages/phpdot/validator)[ RSS](/packages/phpdot-validator/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (6)Versions (4)Used By (0)

phpdot/validator
================

[](#phpdotvalidator)

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

[](#install)

```
composer require phpdot/validator
```

RequirementVersionPHP&gt;= 8.3phpdot/error^1.2Quick Start
-----------

[](#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
------------------

[](#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
------------

[](#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 chainper field]
        CTX[ValidationContext]
        DATA -.->|input| V
        RULES -.->|input| V
        V -->|builds| CTX
    end

    V -->|adds entries| BAG
    BAG --> RESP[Controller responsetoArray / 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

[](#validate-flow)

 ```
flowchart TD
    A["validate(data, rules)"] --> B[Per field]
    B --> C[Build ValidationContext]
    C --> D[Next rule in chain]
    D --> E{Sometimes &&field absent?}
    E -->|yes| Z[Skip rest of chain]
    E -->|no| F{Nullable &&value null?}
    F -->|yes| Z
    F -->|no| G["rule->passes(value, ctx)"]
    G -->|true| D
    G -->|false| H{rule haserror 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
--------------

[](#rule-reference)

### Presence

[](#presence)

RuleBehavior`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

[](#type)

RuleBehavior`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

[](#size)

RuleWorks on`Min($n)` / `Max($n)` / `Between($min, $max)` / `Size($n)`Numeric value, string length (mb), or array count### Format

[](#format)

RuleBehavior`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`)### Choice

[](#choice)

RuleBehavior`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)

[](#comparison-cross-field-aware)

RuleBehavior`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

[](#date)

RuleBehavior`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

[](#database)

RuleBehavior`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 doesThe 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

[](#closure)

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

---

Working Examples
----------------

[](#working-examples)

### Signup Form

[](#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

[](#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

[](#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

[](#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
------------

[](#error-output)

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

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

### JSON API Response

[](#json-api-response)

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

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

[](#form-re-render-with-phpdotsession--phpdottemplate)

```
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 %}
    {{ fieldErrors[0].message }}
{% endif %}
```

---

Strict by Design
----------------

[](#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
---------

[](#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
-----------

[](#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
-------

[](#license)

MIT

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance92

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

3

Last Release

38d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/62e82421bda4b5d6ba9a47ba6d88caca060dcd0d1a2862f351f3a97657385db0?d=identicon)[phpdot](/maintainers/phpdot)

---

Top Contributors

[![phpdot](https://avatars.githubusercontent.com/u/252500?v=4)](https://github.com/phpdot "phpdot (3 commits)")

---

Tags

validatorvalidationrulesphpdot

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/phpdot-validator/health.svg)

```
[![Health](https://phpackages.com/badges/phpdot-validator/health.svg)](https://phpackages.com/packages/phpdot-validator)
```

###  Alternatives

[respect/validation

The most awesome validation engine ever created for PHP

6.1k39.0M405](/packages/respect-validation)[opis/json-schema

Json Schema Validator for PHP

64841.2M257](/packages/opis-json-schema)[vlucas/valitron

Simple, elegant, stand-alone validation library with NO dependencies

1.6k4.6M135](/packages/vlucas-valitron)[intervention/validation

Additional validation rules for the Laravel framework

6777.1M18](/packages/intervention-validation)[proengsoft/laravel-jsvalidation

Validate forms transparently with Javascript reusing your Laravel Validation Rules, Messages, and FormRequest

1.1k2.3M50](/packages/proengsoft-laravel-jsvalidation)[wixel/gump

A fast, extensible &amp; stand-alone PHP input validation class that allows you to validate any data.

1.2k1.4M31](/packages/wixel-gump)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
