sujal / validation
A PHP validation library built around reusable Rule classes.
Requires
- php: ^8.2
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHP validation library built around reusable Rule classes.
You describe valid data with an immutable Schema of Field objects. Each check is a Rule class. A ValidationEngine runs the schema.
Built around SOLID boundaries — not a pile of if statements.
- PHP 8.2+
- No framework required
- MIT licensed
Install
composer require sujal/validation
Inside this repository:
composer install
Quick start
<?php require __DIR__ . '/vendor/autoload.php'; use Sujal\Validation\Field; use Sujal\Validation\Schema; use Sujal\Validation\Validator; use Sujal\Validation\Rules\Required; use Sujal\Validation\Rules\Email; $schema = Schema::make( Field::named('email')->rules(new Required(), new Email()), ); $result = Validator::check([ 'email' => 'ada@example.com', ], $schema); if ($result->passed()) { print_r($result->validated()); } else { print_r($result->errors()->all()); }
Flow in plain English:
- Build a
SchemafromFielddefinitions - Hand input + schema to
Validator::check(...) - Read
passed()/errors()/validated()
Why this shape (SOLID)
| Principle | How this library uses it |
|---|---|
| Single responsibility | Schema describes, ValidationEngine runs, MessageFormatter writes messages, DataReader reads paths |
| Open/closed | New rules are new classes — the engine does not change |
| Liskov | Any Rule is interchangeable; markers like AllowsNull extend behaviour safely |
| Interface segregation | Small contracts: Rule, DataReader, MessageCatalog, FormatsMessages |
| Dependency inversion | The engine depends on interfaces, not concrete helpers |
Fields and schemas are immutable. Calling ->bail() returns a new field — no hidden “current field” state.
A fuller example
<?php use Sujal\Validation\Field; use Sujal\Validation\Schema; use Sujal\Validation\Validator; use Sujal\Validation\Rules\Required; use Sujal\Validation\Rules\Email; use Sujal\Validation\Rules\Min; use Sujal\Validation\Rules\Confirmed; use Sujal\Validation\Rules\In; // This is the data you want to check — form POST, JSON body, etc. $input = [ 'name' => 'Ada Lovelace', 'email' => 'ada@example.com', 'password' => 'secret123', 'password_confirmation' => 'secret123', 'role' => 'admin', ]; $schema = Schema::make( Field::named('name')->rules(new Required(), new Min(2)), Field::named('email')->rules(new Required(), new Email())->label('email address'), Field::named('password')->rules(new Required(), new Min(8), new Confirmed())->bail(), Field::named('role')->rules(new Required(), new In('admin', 'editor', 'viewer')), ); $result = Validator::check($input, $schema); if ($result->failed()) { foreach ($result->errors()->all() as $field => $messages) { foreach ($messages as $message) { echo $field . ': ' . $message . PHP_EOL; } } }
Reuse schemas
Schemas are plain values. Merge the pieces you need:
$input = [ 'email' => 'ada@example.com', 'password' => 'secret123', 'name' => 'Ada', ]; $credentials = Schema::make( Field::named('email')->rules(new Required(), new Email()), Field::named('password')->rules(new Required(), new Min(8)), ); $profile = Schema::make( Field::named('name')->rules(new Required(), new Min(2)), ); $registration = $credentials->merge($profile); $result = Validator::check($input, $registration);
Nested data and wildcards
Dotted paths read nested arrays:
Field::named('user.profile.email')->rules(new Required(), new Email())
Wildcards validate each item in a list:
$schema = Schema::make( Field::named('items.*.email')->rules(new Required(), new Email()), ); $result = Validator::check([ 'items' => [ ['email' => 'a@example.com'], ['email' => 'bad'], ], ], $schema); // Fails on items.1.email
Nullable and Sometimes
use Sujal\Validation\Rules\Nullable; use Sujal\Validation\Rules\Sometimes; // null is allowed; other rules skipped when value is null Field::named('nickname')->rules(new Nullable(), new Email()) // only validate when the key exists (PATCH-friendly) Field::named('email')->rules(new Sometimes(), new Email())
Labels and custom messages
$schema = Schema::make( Field::named('email') ->rules(new Required(), new Email()) ->label('work email') ->message('Required', 'Please enter your work email.'), )->messages([ // schema-wide catalog: attribute.RuleKey 'email.Email' => 'That work email does not look right.', ]);
Placeholders:
:attribute→ label or humanized field name- rule-specific ones like
:min,:max,:other
Reading errors
$result->failed(); $result->errors()->all(); $result->errors()->first('email'); $result->errors()->firstMessage(); $result->errors()->flatten(); $result->validated(); // nested array of fields that passed
Throw instead of checking:
use Sujal\Validation\ValidationException; $input = [ 'email' => 'ada@example.com', ]; $schema = Schema::make( Field::named('email')->rules(new Required(), new Email()), ); try { $result = Validator::checkOrFail($input, $schema); $clean = $result->validated(); } catch (ValidationException $e) { $messages = $e->errorMessages(); }
Optional fields
Without Required, blank values skip most rules:
Field::named('email')->rules(new Email()) // '' passes
Conditional required
use Sujal\Validation\Rules\RequiredIf; Field::named('company_name')->rules( new RequiredIf('account_type', 'business') )
Nested other-fields work too: new RequiredIf('account.type', 'business').
One-off custom checks
use Sujal\Validation\Rules\Callback; Field::named('sku')->rules( new Required(), new Callback( fn (string $attribute, mixed $value): bool => is_string($value) && str_starts_with($value, 'SKU-'), 'The :attribute must start with SKU-.' ) )
Write your own rule
<?php namespace App\Rules; use Sujal\Validation\Contracts\Rule; final class StartsWithSku implements Rule { public function passes(string $attribute, mixed $value, array $data = []): bool { return is_string($value) && str_starts_with($value, 'SKU-'); } public function message(): string { return 'The :attribute must start with SKU-.'; } public function replacements(): array { return []; } }
Use marker interfaces when the engine must treat the rule specially:
ImplicitRule— always run, even if blank (Required)AllowsNull— skip remaining rules when value isnullValidatesWhenPresent— skip the field when the key is missing
Swap dependencies
use Sujal\Validation\ValidationEngine; use Sujal\Validation\Validator; $input = [ 'email' => 'ada@example.com', ]; $schema = Schema::make( Field::named('email')->rules(new Required(), new Email()), ); $engine = new ValidationEngine( reader: $yourDataReader, formatter: $yourFormatter, ); $validator = new Validator(engine: $engine); $result = $validator->validate($input, $schema);
Built-in rules
| Rule | What it checks |
|---|---|
Required |
Present and not blank |
RequiredIf |
Required when another field matches |
Nullable |
Allow null; skip other rules when null |
Sometimes |
Only validate if the key exists |
Email |
Email shape |
Integer / Numeric |
Numbers |
Str / Arr / Boolean |
Types |
Min / Max / Between / Length |
Size |
In / Regex / Url |
Format / allow-list |
Same / Confirmed |
Field matches |
Callback |
Your closure |
Import from Sujal\Validation\Rules.
Run the tests
composer test
License
Open source under the MIT License.