phpdot / validator
Strict, type-safe validation with structured error codes for the PHPdot ecosystem.
Requires
- php: >=8.3
- phpdot/error: ^1.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpdot/container: ^1.7
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
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