ahmednour / validation
High-performance PHP validation library optimized for large datasets with Laravel integration.
Requires
- php: >=8.1
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0
- symfony/uid: ^6.0|^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.2.0
- v0.1.0
- dev-claude/relaxed-pascal-somv8o
- dev-claude/vigilant-lovelace-9vpt55
- dev-claude/youthful-hawking-8fp0kp
- dev-p1-build-a-reproducible-benchmark-suite-and-perf
- dev-p1-add-php-and-laravel-compatibility-matrix-with
- dev-p1-make-validated-fully-support-nested-and-wildc
- dev-p1-introduce-an-explicit-native-compilation-cont
- dev-p1-build-laravel-validation-parity-test-suite
- dev-p0-never-silently-skip-unsupported-rules-in-the
- dev-readme-is-missing
This package is auto-updated.
Last update: 2026-10-11 07:31:12 UTC
README
The High-Performance PHP Validation Library
ahmednour/validation is a blazing fast, memory-efficient validation library designed for high-performance applications. Whether you are processing large datasets, building high-frequency APIs, or running long-lived processes (Octane, Swoole), this library handles it with minimal overhead.
⚡ Performance at a Glance
On realistic import/API datasets, ahmednour/validation runs 17–23× faster than Laravel's validator through the drop-in FastValidator::make() API. It is 30–43× faster with a compiled schema reused across rows, and up to 94× faster once the schema is compiled to native PHP. Memory stays flat per row.
| Dataset | Rows | Laravel Validator | FastValidator::make() per row |
Compiled schema (engine) | Native compiled |
|---|---|---|---|---|---|
| User import (6 fields: string/email/integer/boolean/url/alpha_dash) | 100,000 | 3,316 rows/s | 72,177 rows/s (21.8×) | 138,768 rows/s (41.8×) | 311,833 rows/s (94×) |
| Order lines ETL (7 fields: in, date_format, numeric, …) | 100,000 | 4,178 rows/s | 73,041 rows/s (17.5×) | 126,261 rows/s (30.2×) | — ¹ |
| Nested API payload (8 fields: dot paths, regex, required_if, array) | 100,000 | 3,524 rows/s | 61,465 rows/s (17.4×) | 118,127 rows/s (33.5×) | — ¹ |
Generated by the benchmark suite (
php benchmarks/run.php), results inbenchmarks/results/reference.md. Environment: PHP 8.4.19, illuminate/validation v11.47.0, Intel(R) Xeon(R) Processor @ 2.10GHz (4 CPUs), OPcache/JIT off, CLI. Laravel was measured at 10,000 rows; its throughput is flat, so that rate is the 100,000-row baseline. One-time costs (compiling a schema ≈0.05–0.16 ms, generating a native artifact ≈2 ms) are measured separately and excluded from steady-state throughput. ¹ Not every rule is natively compilable yet (in,regex,date_format, … run on the engine); see the rule matrix.
🌟 Why ahmednour/validation?
- Compile Once, Validate Many: Schemas are compiled into optimized execution plans, eliminating repetitive parsing overhead.
- Native Code Generation: Automatically generates raw PHP code for your validation rules, bypassing reflection and dynamic calls entirely.
- O(1) Memory Usage: Stream validated data from massive datasets (CSVs, JSON streams) without ever loading everything into RAM.
- Laravel Compatible: Drop-in support for Laravel rules. Use it alongside standard validators or replace them entirely.
- Octane Ready: First-class support for Laravel Octane, Swoole, and RoadRunner with stateless validation and worker pools.
📚 Table of Contents
- Installation
- Compatibility
- Quick Start
- Key Features & Documentation
- Streaming & Large Datasets
- Error Collection Strategies for Large Imports
- Chunked & Batch Validation
- Working with Validation Results
- Custom Validation Rules (Closures)
- Conditional Field Rules
- Supported Rules
- Parity & Compatibility
- Localization & Custom Messages
- Long-Running Processes (Octane, Swoole, RoadRunner)
- Configuration
- Testing
- License
📦 Installation
composer require ahmednour/validation
✅ Compatibility
Every combination below is exercised on every push/PR by the CI matrix
— for each cell that CI job runs the full PHPUnit suite (including the Laravel integration tests
in tests/Unit/Parity/ + LaravelRuleParserTest, and the native-compilation tests in
NativeCompilerTest + tests/Unit/Compilation/) plus PHPStan level 8. A combination not listed
here is not tested and not supported — it may work, but breakage in it isn't a bug.
| PHP \ Laravel | 10.x | 11.x | 12.x | 13.x |
|---|---|---|---|---|
| 8.1 | ✅ | ⛔ requires PHP >= 8.2 | ⛔ requires PHP >= 8.2 | ⛔ requires PHP >= 8.3 |
| 8.2 | ✅ | ✅ | ✅ | ⛔ requires PHP >= 8.3 |
| 8.3 | ✅ | ✅ | ✅ | ✅ |
| 8.4 | ➖ not tested | ✅ | ✅ | ✅ |
- CI additionally runs an
illuminate/validation:^10.0install withcomposer update --prefer-loweston PHP 8.1 to catch code that only happens to work against a newer dependency thancomposer.jsonactually declares. - ahmednour/validation works standalone (no Laravel app) on any PHP version in the table above — see Standalone PHP.
- PHP 8.5+ and Laravel 14+ are not yet in the matrix. Adding a new PHP or Laravel version is a
one-line addition to the
matrix:block in.github/workflows/ci.yml(plus anexclude:row if the new Laravel major raises its minimum PHP version) — no restructuring of the test suite is required.
🚀 Quick Start
1. Standalone PHP
Ideal for scripts, ETL jobs, or non-Laravel projects.
use Vi\Validation\Validator; use Vi\Validation\SchemaValidator; // 1. Define & Compile Schema $schema = Validator::schema() ->field('email')->required()->email() ->field('age')->required()->integer()->min(18) ->compile(); // 2. Create Validator $validator = new SchemaValidator($schema); // 3. Validate $data = ['email' => 'user@example.com', 'age' => 25]; $result = $validator->validate($data); if ($result->isValid()) { // success } else { print_r($result->messages()); }
2. Laravel (Parallel Mode) - Recommended
Use ahmednour/validation explicitly where you need performance, keeping standard Laravel validation elsewhere.
use Vi\Validation\Laravel\Facades\FastValidator; public function store() { // Use the Facade just like Validator::make() $validator = FastValidator::make(request()->all(), [ 'email' => 'required|email', 'name' => 'required|string|max:100', ]); if ($validator->fails()) { return back()->withErrors($validator)->withInput(); } $validated = $validator->validated(); // ... }
3. Laravel (Override Mode)
Transparently route standard Validator::make() calls through the fast engine.
- Publish config:
php artisan vendor:publish --tag=config --provider="Vi\Validation\Laravel\FastValidationServiceProvider" - Edit
config/fast-validation.php:'mode' => 'override',
- Use Laravel validation as usual. Supported rules will be accelerated automatically.
4. Streaming with Laravel Facade
Process large datasets memory-efficiently using the Laravel-style API. The FastValidator::make() method accepts iterables (generators, iterators) and provides a stream() method.
use Vi\Validation\Laravel\Facades\FastValidator; // Assume $inputs is a Generator or large array $validator = FastValidator::make($inputs, $rules); $validCount = 0; $invalidCount = 0; $totalErrors = 0; // Stream results one by one - O(1) Memory Usage foreach ($validator->stream() as $index => $result) { if ($result->isValid()) { $validCount++; // Process valid row... } else { $invalidCount++; $totalErrors += count($result->errors()); // Log errors... } }
📖 Key Features & Documentation
🌊 Streaming & Large Datasets
Validating 100,000 rows? Don't crash your server. Use stream() to process records one by one with constant memory usage.
$schema = Validator::schema() ->field('id')->required()->integer() ->compile(); $validator = new SchemaValidator($schema); // Zero memory spikes, even with 1M+ rows foreach ($validator->stream($largeDataset) as $result) { if (!$result->isValid()) { // Log error } }
SchemaValidator also exposes a few other memory-conscious helpers built on top of stream():
// Run a callback per row without ever storing results $validator->each($rows, function ($result, int $index) { if (!$result->isValid()) { Log::error("Row {$index} failed", $result->errors()); } }); // Yield only the rows that failed foreach ($validator->failures($rows) as $index => $result) { // ... } // Stop at the very first failure (fail-fast) $firstFailure = $validator->firstFailure($rows); // ...or keep track of *which* row failed, without re-validating anything if ($failure = $validator->firstFailureWithIndex($rows)) { // $failure->index: zero-based position in iteration order // $failure->key: the key the source produced (array key / generator key, e.g. a CSV line number) echo "Row {$failure->key} failed: " . $failure->result->first(); } // Cheap boolean check over an entire dataset $allGood = $validator->allValid($rows);
⚠️
validateMany()materializes every result in memory — reach for it only on small datasets. Preferstream(),each(), orfailures()for anything with more than a few thousand rows.
Streaming guarantees
- Lazy, never materialized. Arrays, generators, iterators, DB cursors and Laravel
LazyCollections are consumed one row at a time: each result is yielded before the next row is read.ChunkedValidator::streamFailures()/countFailures()don't buffer either. - Bounded memory. Memory added by validation is bounded by one row and its result, not by the dataset size, as long as you don't keep the results. A
ValidationResultholds its row's data and errors, so keep only what you need, such as the key anderrors(). - Immutable results. Each yielded
ValidationResultis an independent value; validating later rows never changes an earlier result. - Safe early exit.
breaking out of a stream, or an exception thrown by the source mid-stream, leaves the validator immediately reusable. - Your keys, if you want them. Pass
preserveKeys: truetostream(),failures(),each()orChunkedValidator::streamFailures()to receive the source's own keys (a generator'syield $lineNo => $row, a keyed array, primary keys) instead of zero-based positions.
// CSV import keyed by line number, streamed straight from disk. $rows = (function () use ($handle, $header) { $line = 1; while (($fields = fgetcsv($handle)) !== false) { yield ++$line => array_combine($header, $fields); } })(); foreach ($validator->failures($rows, preserveKeys: true) as $line => $result) { $report[] = "Line {$line}: " . $result->first(); }
Memory and throughput from php tests/benchmark_streaming.php: a generator source, 4 fields, 5% invalid rows, PHP 8.4 CLI, OPcache and JIT off. "Peak added" is the peak memory validation added on top of the baseline:
| Rows | Path | stream() rows/s |
stream() peak added |
validateMany() peak added |
|---|---|---|---|---|
| 10,000 | engine | 235,214 | 79 KB (first-run warm-up) | 6.3 MB |
| 100,000 | engine | 241,650 | 3.3 KB | 62.2 MB |
| 1,000,000 | engine | 231,709 | 3.3 KB | — |
| 10,000 | native | 550,461 | 4.3 KB | 6.0 MB |
| 100,000 | native | 534,114 | 4.3 KB | 59.5 MB |
| 1,000,000 | native | 502,146 | 4.3 KB | — |
🧮 Error Collection Strategies for Large Imports
When you validate millions of rows, the error data you keep costs more than the validation itself. There are two independent controls.
1. How much detail per row: ErrorMode. Validity never depends on the mode; only the detail kept changes.
| Mode | Keeps | Use for |
|---|---|---|
ErrorMode::All (default) |
every failing rule (bounded by max_errors) |
APIs / forms that show a full error bag |
ErrorMode::FirstPerField |
the first failing rule per field (as if every field had bail) |
imports reporting one problem per column |
ErrorMode::FirstPerRow |
the first error of the row, then stops | "is this row valid, and one reason why" |
ErrorMode::CountOnly |
no details, only per-field counts (errorCount(), errorCountsByField(), failedFields()) |
statistics / dashboards over huge datasets |
use Vi\Validation\Execution\ErrorMode; $validator->setErrorMode(ErrorMode::FirstPerField); // SchemaValidator or FastValidator wrapper // Laravel: 'performance.error_mode' => 'first_per_field' (FAST_VALIDATION_ERROR_MODE)
2. How many failed rows to keep: report(). It validates every row, counts everything and keeps only a bounded sample of failures (with their index/key). An optional callback is a streaming error sink that sees every failure:
$report = $validator->report($rows, maxStoredFailures: 100, onFailure: function (ValidationFailure $f) use ($errorCsv) { fputcsv($errorCsv, [$f->key, implode('; ', $f->result->allMessages())]); }); $report->rowsProcessed; // 1,000,000 $report->failedRows; // 48,213 $report->errorCountsByField; // ['email' => 40,112, 'qty' => 9,870] $report->failures; // first 100 ValidationFailure objects $report->isTruncated(); // true
Both work identically on the engine and native paths. fail_fast and max_errors are now also applied on the native path.
From php tests/benchmark_error_modes.php 200000 (5 fields, 50% invalid rows, PHP 8.4 CLI; "peak added" is the peak memory on top of the baseline):
| Strategy | rows/s | peak added |
|---|---|---|
All, keep every failure (iterator_to_array(failures())) |
52,221 | 552 MB |
FirstPerField, keep every failure |
122,948 | 515 MB |
All + report(100) |
135,091 | 628 KB |
FirstPerField + report(100) |
157,047 | 543 KB |
FirstPerRow + report(100) |
240,937 | 235 KB |
CountOnly + report(0) |
151,642 | 4 KB |
📦 Chunked & Batch Validation
For ETL jobs and imports, ChunkedValidator processes rows in fixed-size batches so you can, for example, bulk-insert valid rows into a database chunk-by-chunk:
use Vi\Validation\Execution\ChunkedValidator; $chunked = new ChunkedValidator($validator); // Run a callback for every chunk of results $chunked->validateInChunks($rows, chunkSize: 500, onChunk: function (int $chunkIndex, array $results) { // $results is a list<ValidationResult> for this chunk }); // Or stream BatchValidationResult objects (Countable + IteratorAggregate) foreach ($chunked->streamChunks($rows, chunkSize: 500) as $chunkIndex => $batch) { echo "Chunk {$chunkIndex}: {$batch->failureCount()} failed of {$batch->count()}\n"; foreach ($batch->failures() as $result) { // handle each failed row } } // Yield only failing rows (with their original index) across the whole dataset foreach ($chunked->streamFailures($rows, chunkSize: 1000) as $index => $result) { // ... } // Count failures without keeping any results in memory $totalFailures = $chunked->countFailures($rows, chunkSize: 1000);
✅ Working with Validation Results
Every validation call returns a ValidationResult with a small, focused API:
| Method | Description |
|---|---|
isValid(): bool |
Whether the data passed all rules. |
data(): array |
The raw input data that was validated. |
validated(): array |
data() with any fields excluded via exclude/exclude_if/exclude_unless/exclude_with/exclude_without removed. |
errors(): array |
Raw, per-field error entries (rule, params, message). |
messages(): array |
Formatted, human-readable messages grouped by field. |
allMessages(): array |
A flat list of every message across all fields. |
firstMessage(string $field): ?string |
The first message for a specific field. |
first(): ?string |
The first message across the whole result. |
$result = $validator->validate($data); if ($result->isValid()) { $safeData = $result->validated(); } else { return response()->json(['errors' => $result->messages()], 422); } echo $result; // "Validation passed." or a newline-joined list of messages
validated() exclusion semantics
validated() starts from data() and removes every field marked by an exclude,
exclude_if, exclude_unless, exclude_with, or exclude_without rule. data() itself is
never mutated — validated() only ever rebuilds the branches it removes from, so unrelated
sibling data (including other keys under the same parent) is left untouched.
Excluded field names are resolved as dot-separated paths, and a * segment matches every
element of the array at that position — the same addressing ->field('profile.email') and
Laravel's 'items.*.sku' rule keys use:
$schema = Validator::schema() ->field('items.*.internal_cost')->excludeIf('include_costs', false) ->compile(); $result = $schema->validate([ 'items' => [ ['sku' => 'A', 'internal_cost' => 4.50], ['sku' => 'B', 'internal_cost' => 9.10], ], 'include_costs' => false, ]); $result->validated(); // ['items' => [['sku' => 'A'], ['sku' => 'B']], 'include_costs' => false]
A few intentional semantics worth calling out (all covered by regression tests in
tests/Unit/Execution/ValidationResultTest.php):
- Missing paths are silently ignored. Excluding
profile.emailwhenprofiledoesn't exist in the data (or isn't an array) is a no-op, not an error. - Excluding a parent and one of its children together is safe, regardless of which is excluded first — once the parent is removed, the now-missing child path is just another missing path.
- Numeric array indexes work like any other segment (
items.0.sku), since PHP array keys already unify string and integer indexing. - This differs from Laravel, whose
Validator::validated()filters the field list down to only rules that were actually run rather than subtracting excluded paths from the input; the observable result — excluded data is absent from the safe payload — is the same.
Drop in one-off rules using a Laravel-style closure — no need to write a dedicated rule class:
use Vi\Validation\Rules\ClosureRule; $schema = Validator::schema() ->field('username') ->required() ->rules(new ClosureRule(function (string $attribute, mixed $value, \Closure $fail) { if (str_contains((string) $value, ' ')) { $fail("The {$attribute} must not contain spaces."); } })) ->compile();
🔀 Conditional Field Rules
Use when() to layer extra rules onto a field depending on another field's value — either a static boolean (evaluated immediately) or a callable (evaluated per-row at validation time):
$schema = Validator::schema() ->field('country')->required()->string() ->field('postal_code') ->required()->string() ->when( fn (array $data) => ($data['country'] ?? null) === 'US', onTrue: fn ($field) => $field->regex('/^\d{5}(-\d{4})?$/'), ) ->compile();
Note: rules added inside
when()are skipped when the field is missing or its value is a blank string (like most non-required*rules; a presentnullor[]is validated, as in Laravel), sowhen()is for layering extra constraints onto a field that already has a base rule such asrequired()— it's not a way to make a field conditionally required. For that, userequiredIf()/requiredUnless()instead.
🛠 Supported Rules
Every standard Laravel rule is supported (except wildcard * attributes); rule objects such as
Rule::in() and your own ValidationRule classes work too. See the full
rule compatibility matrix for engine/native support, Laravel parity status,
dependencies and known differences for every rule.
| Category | Rules |
|---|---|
| Core & Presence | bail, filled, missing, nullable, present, required, sometimes |
| Conditionals | accepted_if, declined_if, exclude, exclude_if, exclude_unless, exclude_with, exclude_without, missing_if, missing_unless, missing_with, missing_with_all, present_if, present_unless, present_with, present_with_all, prohibited, prohibited_if, prohibited_if_accepted, prohibited_if_declined, prohibited_unless, prohibits, required_if, required_if_accepted, required_if_declined, required_unless, required_with, required_with_all, required_without, required_without_all |
| Types | array, boolean, date, decimal, enum, integer, json, list, numeric, string |
| Strings | active_url, alpha, alpha_dash, alpha_num, ascii, base64, doesnt_end_with, doesnt_start_with, email, encoding, ends_with, hex_color, ip, ipv4, ipv6, lowercase, mac_address, not_regex, regex, starts_with, ulid, uppercase, url, uuid |
| Numbers & Size | between, digits, digits_between, max, max_digits, min, min_digits, multiple_of, size |
| Comparison | confirmed, different, gt, gte, in, lt, lte, not_in, same |
| Dates | after, after_or_equal, before, before_or_equal, date_equals, date_format, timezone |
| Arrays | array_keys, contains, distinct, doesnt_contain, in_array, in_array_keys, required_array_keys |
| Files | dimensions, extensions, file, image, max_file_size, mimes, mimetypes, min_file_size |
| Acceptance | accepted, declined |
| Database | exists, unique |
| Auth | current_password, password |
| Others | country, language |
✅ Parity & Compatibility
tests/Unit/Parity/ runs the same rule/data cases through Laravel's real validator and
ahmednour/validation, comparing normalized outcomes (pass/fail and which fields failed, not exact
message text) — this is the safety net that lets us optimize the engine without silently
drifting from Laravel's semantics. It covers every rule category, both execution paths
(ValidatorEngine and the native-compiled path, compared automatically wherever a schema is
native-compilable), and nested attributes of any depth (address.city, a.b.c).
resources/compatibility-matrix.json is the
machine-readable compatibility matrix, rendered as docs/rules.md. For every
rule it records:
- whether the engine supports it;
- whether it is natively compilable;
- its Laravel parity status (
full,partial,divergent,not_applicable,unsupported); - external dependencies (database, auth, uploaded files, network);
- the first Laravel release that has it;
- notes on any difference.
tests/Unit/Parity/CompatibilityMatrixTest.php keeps it from drifting in CI:
- every rule in the registry, and every rule the installed Laravel validator defines, must have an entry;
enginemust match the registry, andnative_compilablemust match whether the class implementsNativeCompilableInterface, so native support is never implied by engine support;- every
full/partialrule must be exercised by a parity test, anddivergentrules by a divergence test; - the README's Supported Rules table must list exactly the supported rules;
docs/rules.mdmust be regenerated (php tests/generate_rule_matrix.php) whenever the matrix changes.
Known, currently-intentional divergences from Laravel (see the matrix for full detail on each):
- Wildcard attributes (
items.*.sku) aren't implemented; a wildcard field name never matches real data. Dot paths of any depth (a.b.c) work like Laravel. accepted_if/declined_if/exclude_if/exclude_unless/missing_if/missing_unlessonly support a single dependent value, where Laravel supports several (accepted_if:field,v1,v2).exists/uniquedon't support the[field]bracket syntax for a per-row dynamic ignore id, or an Eloquent model class as the table parameter. In Laravel they're backed by Laravel's own presence verifier automatically. Standalone, callsetDatabaseValidator(); without one they throw instead of silently passing.current_passwordalways uses the default guard.
Laravel rule objects work too: Rule::in(), Rule::unique()->ignore(), Rule::exists()->where(),
Rule::requiredIf() and the other Stringable rules, plus your own ValidationRule, InvokableRule
and legacy Rule classes (including DataAwareRule and implicit rules). Rule strings use
Laravel's parameter grammar: in:"a,b",c is CSV-quoted, and regex:/^a{1,3}$/ is a single parameter.
An unknown rule name throws UnsupportedRuleException; in override mode it hands the rule set to
Laravel's validator instead.
🌐 Localization & Custom Messages
Fully localized error messages. English and Arabic are built-in (resources/lang/{en,ar}/validation.php), including type-aware variants (e.g. min.string vs min.numeric).
use Vi\Validation\Messages\Translator; $translator = new Translator('ar'); // Switch to Arabic // Point to your own lang directory to add locales or override messages $translator = new Translator('fr', __DIR__ . '/resources/lang'); // Add/override messages at runtime $translator->addMessages(['required' => 'Le champ :attribute est requis.'], 'fr');
To override messages per-field/per-rule and customize attribute names, wrap the translator in a MessageResolver and pass it into SchemaValidator:
use Vi\Validation\SchemaValidator; use Vi\Validation\Execution\ValidatorEngine; use Vi\Validation\Messages\MessageResolver; use Vi\Validation\Messages\Translator; $resolver = new MessageResolver(new Translator('en')); $resolver->setCustomMessages([ 'email.required' => 'Please provide your email address.', // field.rule 'unique' => 'This value has already been taken.', // rule-wide ]); $resolver->setCustomAttributes([ 'email' => 'email address', ]); // The resolver must be given to the engine (used during validation) as well // as to SchemaValidator (used for the precompiled/native validator path). $validator = new SchemaValidator($schema, new ValidatorEngine($resolver), messageResolver: $resolver);
In Laravel, FastValidator::make() accepts the same $messages and $customAttributes arguments as Validator::make().
⚡ Long-Running Processes (Octane, Swoole, RoadRunner)
For worker-based runtimes where the process stays alive across requests, ahmednour/validation avoids re-creating validators on every request:
Vi\Validation\Runtime\ValidatorPool— a pool of reusableStatelessValidatorinstances. Enable it withruntime.poolingin the config (see below);OctaneValidatorProviderwires it into Octane'sWorkerStarting/WorkerStopping/RequestReceived/RequestTerminatedevents automatically.Vi\Validation\Runtime\Workers\SwooleAdapterandRoadRunnerAdapter— thin adapters for wiring the same lifecycle hooks into Swoole and RoadRunner workers directly.
use Vi\Validation\Runtime\ValidatorPool; $pool = new ValidatorPool(maxSize: 10); $pool->onWorkerStart(); // pre-warm on worker boot // Automatic acquire/release around a unit of work $result = $pool->withValidator(function ($validator) use ($schema, $data) { return $validator->validate($schema, $data); // StatelessValidator::validate(CompiledSchema, array) }); $pool->onWorkerStop(); // drain on worker shutdown
Worker-safety guarantees (covered by tests/Unit/Runtime/WorkerSafetyTest.php)
- Per validation.
ValidatorEnginereuses one error collector and one context for speed, and resets both whenvalidate()returns or throws. No partial errors survive, and the last row's data (e.g. a request payload) doesn't stay reachable while the worker idles. Thousands of interleaved validations of different schemas through one engine give exactly the results of fresh engines. - Results are immutable. A
ValidationResultowns copies of its data and errors; later validations never change it. - Per request / borrower.
ValidatorPool::release()(and sowithValidator(), even when the callback throws) restores the engine's settings as they were at creation: fail-fast, max errors, error mode, message resolver, database validator and password hasher.ContextManagercustom messages and attributes are cleared too, so nothing one request configures is visible to the next. - One borrower at a time. A pooled validator is never handed to two borrowers. Double or foreign
release()calls are ignored, and temporary validators created when the pool is exhausted are discarded instead of growing the pool. - Process-wide caches are immutable. Compiled schemas, native closures and fingerprints are keyed by content and safe to share.
- Not coroutine-safe. A single engine/
SchemaValidator/StatelessValidatorinstance must not be used by two coroutines at the same time (e.g. across a Swoole I/O yield inside auniquerule). Give each coroutine its own instance viaValidatorPool::acquire().
⚙️ Configuration
Publish the config file in a Laravel app:
php artisan vendor:publish --tag=config --provider="Vi\Validation\Laravel\FastValidationServiceProvider"
This creates config/fast-validation.php with the following options (each overridable via an environment variable):
| Key | Env Variable | Default | Description |
|---|---|---|---|
mode |
— | parallel |
parallel (opt-in FastValidator facade) or override (route Validator::make() through the fast engine). |
cache.enabled |
FAST_VALIDATION_CACHE |
true |
Cache compiled schemas to avoid recompiling on every request. |
cache.driver |
FAST_VALIDATION_CACHE_DRIVER |
array |
array (per-request) or file (persisted across requests/workers). |
cache.ttl |
FAST_VALIDATION_CACHE_TTL |
3600 |
Cache lifetime in seconds. |
cache.path |
— | storage/framework/validation/cache |
Storage path used by the file cache driver. |
compilation.precompile |
FAST_VALIDATION_PRECOMPILE |
false |
Generate a schema's native PHP validator closure on first use. Existing artifacts (e.g. from FastValidatorFactory::precompile() at deploy time) are loaded regardless. |
compilation.cache_path |
— | storage/framework/validation/compiled |
Where native validators are stored/loaded. See the schema lifecycle. |
security.signing_key |
FAST_VALIDATION_SIGNING_KEY |
APP_KEY |
Secret used to HMAC-sign cached schemas and native artifacts; unverified files are never loaded. See security model. |
performance.fail_fast |
FAST_VALIDATION_FAIL_FAST |
false |
Stop validating a field after its first error. |
performance.error_mode |
FAST_VALIDATION_ERROR_MODE |
all |
Error detail kept per row: all, first_per_field, first_per_row or count_only. See Error Collection Strategies. |
performance.max_errors |
FAST_VALIDATION_MAX_ERRORS |
100 |
Stop collecting errors after this many, to bound worst-case cost on malformed input. |
performance.fast_path_rules |
FAST_VALIDATION_FAST_PATH |
true |
Enable optimized code paths for common rule combinations. |
localization.locale |
FAST_VALIDATION_LOCALE |
en |
Default locale for error messages. |
localization.fallback_locale |
FAST_VALIDATION_FALLBACK_LOCALE |
en |
Locale used when a message is missing in the active locale. |
runtime.pooling |
FAST_VALIDATION_POOLING |
false |
Enable ValidatorPool instance reuse for Octane/Swoole/RoadRunner. |
runtime.pool_size |
FAST_VALIDATION_POOL_SIZE |
10 |
Maximum number of pooled validator instances. |
runtime.auto_detect |
FAST_VALIDATION_AUTO_DETECT |
true |
Auto-detect long-running environments and tune behavior accordingly. |
Native compilation compatibility contract
When compilation.precompile (or a cache_path passed to ValidatorCompiler) is enabled, NativeCompiler tries to turn a schema into a single inlined PHP closure for maximum throughput. A native validator must never change validation semantics compared to the standard ValidatorEngine.
Only a fixed set of rules can currently be inlined: required, nullable, bail, string, integer, numeric, boolean, array, email, url, ip, json, min, max, alpha, alpha_num, and alpha_dash. If a schema contains any other rule (e.g. in, regex, unique, distinct, database rules, closures, ...), NativeCompiler::compile() throws UnsupportedNativeRuleException instead of silently dropping the rule. ValidatorCompiler::writeNativeFor() catches this and simply does not write a native artifact for that schema — SchemaValidator::validate() then finds no native artifact and transparently falls back to ValidatorEngine, so the exact same schema always produces the exact same result whether or not it happened to be native-compilable.
Native artifacts are keyed by the schema's deterministic fingerprint ($schema->fingerprint()), which also encodes the compiler and PHP versions — see docs/native-compilation.md for what is and isn't part of a schema's identity.
To see how any schema executes and why, including which rules keep it off the native path, call ->diagnostics() on a SchemaValidator or FastValidator::make(...). See Diagnostics.
You can check ahead of time whether a schema is fully native-compilable:
$compiler = new \Vi\Validation\Compilation\NativeCompiler(); $compiler->canCompile($schema); // bool $compiler->findUnsupportedRules($schema); // e.g. ['status:Vi\Validation\Rules\InRule']
Outside Laravel, pass the same shape directly to SchemaValidator::build():
$validator = \Vi\Validation\SchemaValidator::build( definition: fn ($schema) => $schema->field('email')->required()->email(), config: [ 'compilation' => [ 'precompile' => true, 'cache_path' => __DIR__ . '/storage/compiled', ], ], );
🧪 Testing
Run the test suite to verify everything is working on your machine:
composer install ./vendor/bin/phpunit
Property-based / fuzz tests (tests/Unit/Property/, group property) generate random schemas and adversarial values: numeric strings, INF/NAN, whitespace, NUL bytes, Unicode, nested arrays, deep dot paths. They check these invariants:
- the native and engine paths return identical errors;
- a reused validator equals a fresh one;
- validation never mutates the input, and
validated()never mutatesdata(); max_errorsalways bounds the error count, in every error mode;- streaming agrees with one-at-a-time validation;
- no rule throws on input that Laravel itself accepts without throwing.
They run with a random seed on every phpunit run. A failure prints the seed and the exact command to replay it:
./vendor/bin/phpunit --group property # random seed VI_FUZZ_SEED=123456 ./vendor/bin/phpunit --group property # replay a failure VI_FUZZ_ITERATIONS=5000 ./vendor/bin/phpunit --group property # longer local run
Every bug they find gets a permanent regression test in tests/Unit/Property/FuzzRegressionTest.php.
📝 License
This project is open-sourced software licensed under the MIT license.