Search by

ahmednour / validation

ahmed-nour-dev

High-performance PHP validation library optimized for large datasets with Laravel integration.

v0.2.0 2026-10-11 07:25 UTC

README

The High-Performance PHP Validation Library

Latest Version on Packagist Total Downloads

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 in benchmarks/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

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.0 install with composer update --prefer-lowest on PHP 8.1 to catch code that only happens to work against a newer dependency than composer.json actually 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 an exclude: 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.

  1. Publish config: php artisan vendor:publish --tag=config --provider="Vi\Validation\Laravel\FastValidationServiceProvider"
  2. Edit config/fast-validation.php:
    'mode' => 'override',
  3. 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. Prefer stream(), each(), or failures() 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 ValidationResult holds its row's data and errors, so keep only what you need, such as the key and errors().
  • Immutable results. Each yielded ValidationResult is 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: true to stream(), failures(), each() or ChunkedValidator::streamFailures() to receive the source's own keys (a generator's yield $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.email when profile doesn'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 present null or [] is validated, as in Laravel), so when() is for layering extra constraints onto a field that already has a base rule such as required() — it's not a way to make a field conditionally required. For that, use requiredIf() / 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;
  • engine must match the registry, and native_compilable must match whether the class implements NativeCompilableInterface, so native support is never implied by engine support;
  • every full/partial rule must be exercised by a parity test, and divergent rules by a divergence test;
  • the README's Supported Rules table must list exactly the supported rules;
  • docs/rules.md must 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_unless only support a single dependent value, where Laravel supports several (accepted_if:field,v1,v2).
  • exists/unique don'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, call setDatabaseValidator(); without one they throw instead of silently passing.
  • current_password always 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 reusable StatelessValidator instances. Enable it with runtime.pooling in the config (see below); OctaneValidatorProvider wires it into Octane's WorkerStarting/WorkerStopping/RequestReceived/RequestTerminated events automatically.
  • Vi\Validation\Runtime\Workers\SwooleAdapter and RoadRunnerAdapter — 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. ValidatorEngine reuses one error collector and one context for speed, and resets both when validate() 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 ValidationResult owns copies of its data and errors; later validations never change it.
  • Per request / borrower. ValidatorPool::release() (and so withValidator(), 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. ContextManager custom 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/StatelessValidator instance must not be used by two coroutines at the same time (e.g. across a Swoole I/O yield inside a unique rule). Give each coroutine its own instance via ValidatorPool::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 mutates data();
  • max_errors always 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.