sandermuller/laravel-fluent-validation
Type-safe, IDE-autocomplete Laravel validation rule builders. Create rules fluently without memorizing strings; each rule exposes only valid methods. Define nested array validation with each()/children(). Optional HasFluentRules trait speeds wildcard validation dramatically (up to 160x).
Turns a silent validation bypass into a loud, actionable error. A wildcard rule key written incorrectly used to apply no validation and let invalid data through unnoticed; it now fails fast with a corrective hint.
A wildcard rule key must contain a .* segment (e.g. items.*.name, or
items.* for a scalar list). A key with a * outside a .* segment — the typo
items* (missing the dot), or a root-level * / *.foo — previously computed an
empty parent internally and was dropped before validation ran. The rule applied
nothing and invalid data passed silently.
Such keys now throw InvalidArgumentException with a corrective hint:
Malformed wildcard rule key [items*]: a wildcard segment must be written as '.*'
(e.g. 'items.*.name'). Did you mean 'items.*'?
This matches the package's existing fail-fast on malformed array-rule keys.
Impact: correctly-formed rules (items.*.name, items.*, addresses.*.postcode,
…) are unaffected — verdicts are identical. Only a previously-malformed key, which
was silently doing nothing, now surfaces as an error. If you hit this on upgrade,
it has been masking a rule that never ran: fix the key to use .*. Root-level
wildcards (*, *.foo) are not supported — nest rules under a named key
(field.*).
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.28.1...1.29.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 233.8ms | 3.2ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2904.0ms | 18.1ms | ~160x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.9ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.7ms | 3.4ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3637.5ms | 71.9ms | ~51x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~16x |
A performance patch for conditional wildcard validation. No runtime behaviour or public API changed — validation verdicts are identical; the conditional path just does less redundant work per item.
When a wildcard array carries sibling-dependent conditionals — required_if,
required_with, and the presence/value conditionals — each item can reduce to a
different effective rule set, so that path could not use the existing dispatch
cache and rebuilt its compiled fast-check closures for every single item. In the
common case where many items reduce to the same rule set (e.g. most rows take
the same conditional branch), that work was repeated needlessly.
These compiled fast-checks are now memoized per distinct reduced rule set, keyed by the same content-sensitive key the per-item validator cache already uses. The reduction itself still runs per item (it depends on each item's data); only the fast-check assembly is reused. Larger arrays with repeated conditional shapes do proportionally less compilation work.
The change is internal to the validation engine. Verdicts are unchanged, covered by the existing conditional/parity suites plus dedicated cache-correctness tests.
UPGRADING.md upgrade guide at the repository root (documents the 1.28
Laravel 11 support drop). It is a GitHub-facing document and is not part of the
distributed package.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.28.0...1.28.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 191.9ms | 2.6ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3272.9ms | 15.9ms | ~206x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 26.3ms | 0.9ms | ~31x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 8.7ms | 2.8ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2599.2ms | 57.7ms | ~45x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~17x |
Drops Laravel 11 from the supported matrix and moves the package's AI-authoring dev tooling onto the boost 1.x line. No runtime code or public API changed — the only consumer-facing effect is the narrowed framework constraint.
Every Laravel 11 release (v11.0.0 through v11.54.0) is now flagged by Packagist security advisories, and there is no advisory-free patch in the 11.x line. Composer's advisory policy refuses to install any of them, so the package could no longer be resolved or tested against Laravel 11.
The supported framework constraint narrows accordingly:
illuminate/*: ^11.0||^12.0||^13.0 -> ^12.0||^13.0
The CI matrix drops its Laravel 11 legs (and the orchestra/testbench ^9.0 requirement that only existed to test them); Laravel 12 and 13 remain, across PHP 8.2 / 8.3 / 8.4 on Ubuntu and Windows.
The package's require-dev boost stack moved to the 1.0 line — sandermuller/package-boost-laravel ^1.0 (pulling boost-core 1.x + package-boost-php 1.x) and sandermuller/boost-skills ^2.5. The .config/boost.php config was updated to the array-argument builder API that boost-core 0.20+ requires. These are developer-only dependencies; they are not installed by consumers and do not affect the package at runtime.
No runtime code touched. No public-API change. No new runtime dependencies.
Drop-in for anyone already on Laravel 12 or 13: composer update sandermuller/laravel-fluent-validation.
Projects still on Laravel 11 must upgrade to Laravel 12+ to take this release. Laravel 11 is past security support and its releases are advisory-blocked; staying on it is not a secure option regardless of this package.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.27.3...1.28.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 108.9ms | 1.6ms | ~69x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 1621.7ms | 9.1ms | ~178x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 14.9ms | 0.5ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 5.3ms | 1.5ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 1721.2ms | 34.9ms | ~49x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~14x |
Patch fix: the always-on FluentRule guideline never reached consumers' AI tooling because the wrong file extension made boost-core skip it.
core.blade.php → core.mdThe package ships an always-on guideline (FluentRule Validation) that boost-core renders into a consumer's CLAUDE.md / AGENTS.md when they allowlist this package. It carried the standing guidance — FormRequests must use HasFluentRules, the FluentRule:: type list, "don't wrap conditional modifiers in Rule::".
The file was plain markdown — zero Blade directives, zero render-time tokens — but its .blade.php extension made boost-core route it through a renderer that no normal consumer registers. Every consumer's boost sync skipped it:
⚠ guideline `core.blade.php` skipped — no renderer registered for its extension.
So the standing guidance never landed in CLAUDE.md / AGENTS.md; contributors only got it on-demand via the fluent-validation* skills, not as always-on context.
Fix: renamed the file to core.md. boost-core renders .md guidelines natively, so the body now reaches every allowlisting consumer with no withSkillRenderers() registration required. No content change.
Surfaced during a downstream consumer audit of synced AI assets.
Drop-in. composer update sandermuller/laravel-fluent-validation, then re-run vendor/bin/boost sync; the guideline now renders without the skip warning.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.27.2...1.27.3
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 197.0ms | 2.7ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3359.9ms | 15.6ms | ~216x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 27.9ms | 0.9ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 9.0ms | 2.8ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2747.6ms | 60.4ms | ~46x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~17x |
Patch fix: conditional-required and presence modifiers combined with nullable() were silently dropped when a FluentRule self-validates.
nullable() no longer drops conditional-required / presence modifiers in self-validationWhen a FluentRule is used as a standalone ValidationRule — inline $request->validate([...]) or Validator::make() without the HasFluentRules trait — it self-validates through SelfValidates. isNullable() skipped validation for a null or absent value whenever nullable was present and the literal required string was absent from the rule's constraints.
Conditional-required and presence modifiers never emit that literal required string — they compile to required_* constraint strings, or to RequiredIf / RequiredUnless objects when built from a closure or bool. So the guard silently dropped the requirement on a null or missing value:
// $enabled = true; the field is omitted or null
FluentRule::email()->requiredIf($enabled)->nullable()
// before: passed — requirement dropped ❌
// after: fails, matching native Laravel ✅
This diverged from both native Laravel (['nullable', Rule::requiredIf(true)]) and the compiled HasFluentRules path, which were already correct — so the gap only surfaced when a FluentRule validated in isolation.
Fix: isNullable() now short-circuits only when the chain carries no presence-forcing modifier. The presence-forcing set is:
required, the string conditionals (requiredIf / requiredUnless / requiredWith / requiredWithout and their _all / _if_accepted / _if_declined variants), and the RequiredIf / RequiredUnless objects.present / presentIf / presentUnless / presentWith / presentWithAllmissing / missingIf / missingUnless / missingWith / missingWithAllDetection matches exact rule names, so required_array_keys (an array-content rule, not a presence requirement) is correctly excluded. prohibited* and exclude* remain short-circuitable — they are satisfied by a null/empty value, which already matched native Laravel.
A second, related gap is closed in the same path: a nullable array no longer short-circuits when it carries nested each() / children() rules. A required child under a null parent is now enforced like native Laravel (and the compiled path) instead of skipped. Wildcard each() still passes on a null parent — it expands to nothing — while fixed children() enforce their sub-rules.
Pinned by tests/RequiredConditionalNullableTest.php: a parity matrix that asserts every conditional-required and presence family — with nullable on and off, across absent / null / empty-string / valid values, plus nested parent-null shapes — behaves identically on the self-validation path, the compiled HasFluentRules path, and native Laravel.
No public-API change. No new dependencies. Same compatibility matrix as 1.27.1.
Reported via #15.
Drop-in. composer update sandermuller/laravel-fluent-validation.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.27.1...1.27.2
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 227.8ms | 3.3ms | ~70x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2908.5ms | 18.1ms | ~161x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.3ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.5ms | 3.3ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3720.0ms | 69.9ms | ~53x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~15x |
Patch fix for a silent gap in the parent-max:N short-circuit on batched-database validation.
BatchLimitRemap now populates Validator::failed() with the tripped ruleWhen each() carries a batched exists/unique rule and the parent array declares max:N, the package short-circuits before any DB query fires (HasFluentRules::assertParentArraysWithinMax). The short-circuit throws BatchLimitExceededException, and BatchLimitRemap::toValidationException() remaps that to Illuminate\Validation\ValidationException so callers see the package's standard exception type.
The remap built a synthetic Validator::make([], []), pushed the error message into the bag, but never populated failedRules. Net effect:
$caught->validator->errors()->keys(); // ['actions'] ✅
$caught->validator->errors()->first(); // human-readable message ✅
$caught->validator->failed(); // [] ❌
Validator::failed() returning [] meant FluentRulesTester::failsWith('actions', 'max') — and any other assertion path that inspects the rule-bag rather than the message bag — couldn't see which rule actually tripped.
The load-bearing detail: the parent-max guard fires before the validator is built, so this path never goes through Laravel's normal Max rule. Reproducers that drive Validator::make directly against the same FluentRule chain hit Laravel's own Max, which populates failedRules correctly — masking the gap for narrow tests.
Fix: the remap now reflects failedRules onto the synthetic validator:
REASON_PARENT_MAX → [$attribute => ['Max' => [(string) $limit]]]REASON_HARD_CAP → [$attribute => ['BatchLimit' => []]]Pinned by:
tests/BatchValidationGuardsTest.php — asserts the post-remap failed() shape directlytests/Testing/FluentRulesTesterClassTargetsTest.php + tests/Fixtures/BailMaxEachFluentFormRequest.php — guards the FluentRulesTester::failsWith(..., 'max') consumer surfaceNo public-API change. No new dependencies. Same compatibility matrix as 1.27.0.
Same matrix as 1.27.0:
Drop-in. composer update sandermuller/laravel-fluent-validation.
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 212.7ms | 3.2ms | ~67x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2793.2ms | 19.0ms | ~147x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.5ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.1ms | 2.8ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3309.7ms | 64.5ms | ~51x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~14x |
dropUnknownFields() — lenient counterpart to failOnUnknownFields()RuleSet::failOnUnknownFields() rejects unknown input keys with a validation error. dropUnknownFields() does the opposite: it strips them silently.
$validated = RuleSet::from([
'name' => FluentRule::string()->required(),
'meta' => FluentRule::array()->required()->children([
'type' => FluentRule::string()->required(),
]),
])->dropUnknownFields()->validate($request);
// Input: ['name' => 'John', 'meta' => ['type' => 'admin', 'secret' => 'leak']]
// Output: ['name' => 'John', 'meta' => ['type' => 'admin']]
Top-level keys outside the rule set are already excluded from validated(); this flag extends the same behavior to nested array shapes declared via children(), each(), or dotted rule keys. Maps to Laravel's Validator::$excludeUnvalidatedArrayKeys, but gives per-RuleSet control instead of relying on whatever the host factory's flag happens to be set to — useful when an application has called Factory::includeUnvalidatedArrayKeys() globally and a specific call site needs the strict default back.
If both dropUnknownFields() and failOnUnknownFields() are set on the same rule set, failOnUnknownFields() wins — unknown keys trigger a validation error before the drop ever applies. Order is deterministic and pinned by tests/RuleSetTest.php.
Wildcard rule sets (anything built with each()) take a different path internally: the per-item validators don't see siblings, so they can't strip cross-item unknown keys. When dropUnknownFields() is combined with each(), the rule set falls back to a single fully-expanded validator so the flag applies uniformly. The fallback is correct but bypasses the batched-database verifier and per-item fast-check optimizations — fine for typical FormRequest payloads, worth knowing if you're stripping unknowns on five-figure-row imports.
validate(Request) / check(Request)RuleSet::validate() and RuleSet::check() now accept array|Illuminate\Http\Request:
// Before
$validated = RuleSet::from([...])->validate($request->all());
// 1.27
$validated = RuleSet::from([...])->validate($request);
When a Request is passed, the package calls $request->all() once inside a private normalizer at the library boundary, then runs the rest of the pipeline against the array. Functionally identical to the explicit $request->all() form — but the unsafe-input read happens inside the package, not in your controller.
The motivation is downstream static-analysis rules that forbid $request->all() / $request->input() outside FormRequest classes — they trip on the explicit form even when the very next call validates the data. With the overload, ad-hoc controller validations stay clean for those rules without forcing a FormRequest extraction. For real form requests (auth-gated endpoints, reusable rule sets, anything non-trivial), keep using HasFluentRules — the overload is for one-shot ad-hoc validations only.
check() accepts the same overload for the errors-as-data path.
stopOnFirstFailure now propagates through validateStandard()Pre-existing gap. RuleSet::validateStandard() — the fully-expanded fallback path — built its validator without applying ->stopOnFirstFailure($this->stopOnFirstFailure). The non-wildcard branch and the wildcard top-validator both did; only the fallback didn't.
The bug surfaces any time validateStandard() is reached: rules with distinct, and — new in 1.27 — rules with dropUnknownFields() combined with each(). Fix is a single chain call; pinned by tests/RuleSetTest.php.
illuminate/http now declared in requireThe package references Illuminate\Http\UploadedFile from PresenceConditionalReducer and (new in 1.27) Illuminate\Http\Request from RuleSet. Previously the composer.json require block only listed illuminate/contracts, illuminate/support, and illuminate/validation — none of which transitively guarantee illuminate/http. In practice every install ended up with it via laravel/framework, but a downstream consumer using only the standalone illuminate components would have hit an undeclared-dependency runtime fail.
illuminate/http is now in require with the same ^11.0||^12.0||^13.0 constraint as the other illuminate packages. No version change for anyone whose lockfile already pulled it in transitively.
If your application has called $factory->includeUnvalidatedArrayKeys() globally and you previously worked around it with explicit $validator->excludeUnvalidatedArrayKeys = true; after Validator::make, you can now express that directly on the rule set with ->dropUnknownFields().
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.26.0...1.27.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 230.1ms | 3.3ms | ~69x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2837.0ms | 18.6ms | ~153x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.4ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.3ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3576.4ms | 69.4ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~15x |
FastCheckCompiler::compile()Repeated FormRequest validations recompile the same pipe-delimited rule strings into the same closures. 1.26 memoizes the result by rule string — first call builds the closure, subsequent calls hit a process-local map.
Login form benchmark: ~7x → ~12x speedup vs native Laravel. Wildcard-heavy scenarios already routed through RuleCacheKey upstream and don't move on the bench, but the flat-rule FormRequest path (login forms, settings forms, simple CRUD) doubles its relative speedup.
The cache is bounded and Octane-aware:
in: lists, generated regexes) can't grow the cache without bound on long-lived workers. Above the cap the cache resets; correctness is preserved, only the warm hit-rate momentarily drops.CoreValueCompiler::parseDateLiteral calls strtotime() at compile time and bakes the timestamp into the closure. Caching after:today / before:now / after_or_equal:tomorrow / before_or_equal:+1 week / date_equals:today would freeze the relative timestamp for the lifetime of the Octane worker and silently drift validation after midnight. These rules recompile per-call by design — cheap and correct.Pinned by tests/FastCheckCompilerCacheTest.php (7 cases) covering both the stable-rule reuse contract and the no-reuse contract for the five date-comparison opcodes.
OptimizedValidator::passes()Phase 2 used to call evaluateConditionals() on every attribute in the rules map — scanning each rule list for exclude_unless / exclude_if tuples even when the attribute had none. For FormRequests with sparse conditionals over many attributes that scan was the dominant per-call cost.
1.26 replaces it with a single up-front pass (indexConditionalAttrs()) that builds a flat [attribute => list<{action, field, values}>] map, then iterates only attributes that actually have conditionals. The post-eval fast-check branch now runs against pre-parsed tuples instead of re-scanning the rule list.
FormRequest with conditional rules (100 items × 10 fields, 6 conditional): median 13–16ms → ~10ms (~30% faster). The benchmark harness scenarios use RuleSet::validate() (which routes through ItemValidator, not OptimizedValidator) so they don't reflect the win — but FormRequest paths in real apps see it directly.
passes() itself was decomposed into four helpers (runFastCheckPhase, runConditionalPhase, tryFastCheckRemaining, finalizeWithoutParent) to keep cognitive complexity under threshold during the rewrite.
Three small cleanups in the per-item validation loop. None move the benchmark needle individually (variance dominates), but they match existing intent and avoid pointless work on every call:
CoreValueCompiler: replaced in_array($value, [null, '', []], true) with an explicit === chain. The adjacent comment already advertised this — the code just hadn't been updated.ItemValidator: dropped array_values() before passing the fast-check map to passesAllFastChecks. The collector iterates values via foreach, so the rekey was free busywork on every item.ItemErrorCollector: widened the param type to iterable to match.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.25.0...1.26.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 227.8ms | 3.3ms | ~68x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3052.3ms | 21.0ms | ~145x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.4ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.4ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3533.0ms | 67.9ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~15x |
FluentRule::integer(strict: true)FluentRule::integer(strict: true)->required()
// compiles to: numeric|integer:strict
Rejects numeric strings ("42") — only is_int($value) passes. Useful for JSON ingest paths where you've already type-cast and want validation to reject anything that slipped through as a string.
Laravel version requirement: integer:strict is honored by Laravel's validateInteger only on Laravel 12.23+. The factory accepts strict: true on every supported Laravel version and the rule still compiles to numeric|integer:strict, but on L<12.23 strict mode is a runtime no-op — Laravel's validator silently ignores the modifier. Use strict: true for forward-compatibility now, with the expectation that strict-mode rejection only kicks in once you're on Laravel 12.23+.
Signature: FluentRule::integer(?string $label = null, ?string $message = null, bool $strict = false). Positional (label, message) callers from prior versions are unchanged; strict: lives at slot 3 to preserve BC.
FluentRule::date(message:) and FluentRule::dateTime(message:)FluentRule::date(message: 'Bad date.')->required()
FluentRule::dateTime(message: 'Bad timestamp.')->required()
Both factories now accept message: and route it to the type-check key — 'date' by default, or 'date_format' once ->format(...) is called. A pinned message migrates automatically on format():
$rule = FluentRule::date(message: 'Bad date format.')->format('Y-m-d');
// customMessages now has 'date_format' => 'Bad date format.', not 'date'
If a messageFor('date_format', '…') was set explicitly before format(), that explicit binding wins — the migration won't clobber user intent.
Behavior matches FluentRule::string(message:): chained ->message() after the factory targets the same key and overwrites (last-write-wins). Plain FluentRule::date()->message('…') (no factory message) still raises LogicException if no rule has been added — the fail-fast guard for accidental call-order bugs is preserved by gating the seeded lastConstraint on the factory-message branch.
Previously CoreValueCompiler collapsed integer:strict to a generic 'integer' => true flag and emitted a filter_var($v, FILTER_VALIDATE_INT) !== false closure. That closure returned true for "42", which signalled "definitely valid" to the optimized validator — the rule was removed and Laravel never ran. On Laravel 12.23+ that meant strict-mode rejection silently broke on the FluentRule optimized path (HasFluentRules, RuleSet::validate(), etc.), even though bare Validator::make() would have rejected the same value.
1.25 tracks an integer.strict config flag and emits is_int($v) when set:
// CoreValueCompiler.php — branched closure under strict
$checks[] = ($c['integer.strict'] ?? false) === true
? static fn (mixed $v): bool => is_int($v)
: static fn (mixed $v): bool => filter_var($v, FILTER_VALIDATE_INT) !== false;
The closure now returns false for "42" under strict, which defers the rule to Laravel's validator. On L12.23+, Laravel rejects the value and the user sees the correct error. On older Laravel, Laravel still lenient-passes (no strict awareness there), so the runtime outcome on those versions is unchanged — same as before, just routed through Laravel instead of short-circuiting.
The same colon-split bug was fixed in ValueTypePredicates::predicateFor(), the helper that drives BatchDatabaseChecker::filterValuesByType(). Without the fix, numeric strings under integer:strict would have entered the batched whereIn group used for exists/unique queries on wildcard arrays, causing avoidable DB work and risking the hard-cap remap on values Laravel 12.23+ would reject anyway. Both predicates now agree.
No measurable benchmark impact — the is_int path is a single function call, comparable to filter_var for valid inputs. Surfaced by the prefer-lowest matrix during release prep when the new strict-mode tests caught the first gap, then by an adversarial code review pass for the second.
This release ships a substantial README rewrite, mostly editorial:
HasFluentRules flow. Bare $request->validate() and Validator::make() demoted to a smaller ### Other contexts subsection at the end.min:5 polymorphism across types, the unique:users,email,$ignoreId,id slot order, date_equals vs same vs before_or_equal).// also: for related siblings. Mutually exclusive method chains were factored apart (case, format, sign, exact-vs-aspect dimensions). Missing methods backfilled (ascii(), *OrEqual on dates, array doesntContain(), distinct modes, password confirmed()).Rule class") expanded from 11 rows to 7 grouped sub-tables covering all 25 Illuminate\Validation\Rule:: static methods plus the FluentRule-only additions.[!CAUTION] callout up front spelling out the four supported pathways. Bare $request->validate() drops the label silently; readers see the warning before the first example now.mergeRecursive headline became "Child form request loses or corrupts parent rules" (symptom-first). The Filament tip now points at HasFluentValidationForFilament (the canonical fix). The Rector error-string list dropped — most of those were fixed in the companion 1.0.<details> blocks removed — anchors don't reliably resolve inside collapsibles on GitHub.migration-patterns.md adds an integer:strict row in the type-choice matrix and notes the L12.23+ requirement.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.24.0...1.25.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 237.1ms | 3.3ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2928.7ms | 18.2ms | ~161x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 33.0ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.6ms | 3.3ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3498.4ms | 71.1ms | ~49x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
RuleSet::modifyEach / modifyChildren sugarThe 1.23.0 primitive form required a modify() + lambda at every extend site:
// 1.23.0
return parent::rules()->modify('answers', fn (ArrayRule $rule) =>
$rule->addEachRule('id', FluentRule::numeric()->nullable())
);
1.24.0 adds a one-expression sugar:
// 1.24.0 — later-wins merge semantic
return parent::rules()->modifyEach('answers', [
'id' => FluentRule::numeric()->nullable(),
]);
The two new methods:
RuleSet::modifyEach(string $field, array $rules): self — wraps mergeEachRules() on the stored ArrayRule. Later-wins merge (string-keyed; matches array_merge semantics and RuleSet::merge()).RuleSet::modifyChildren(string $field, array $rules): self — symmetric sugar over FieldRule::mergeChildRules(). FieldRule-only in 1.24.0; ArrayRule::children() extension isn't covered (no consumer demand surfaced — reopen if that changes).For strict add-only semantics (collision throws), the 1.23.0 primitive form remains:
return parent::rules()->modify('answers', fn (ArrayRule $rule) =>
$rule->addEachRule('id', ...) // throws on existing-key collision
);
Both methods throw \LogicException when the target field is missing from the rule set (use put()) or when the stored rule isn't of the expected builder type (modifyEach → ArrayRule; modifyChildren → FieldRule). List-shape each(FluentRule::string()) state propagates CannotExtendListShapedEach through modifyEach, same as the primitive.
ArrayRuleArrayRule::getEachRules() returned ValidationRule|array<string, ValidationRule>|null — a union type that shipped with 1.23.0 for BC but made correct downstream usage subtle (see hihaho's pre-1.23 Arr::wrap($rule->getEachRules()) footgun). Two focused readers replace it:
ArrayRule::getEachKeyedRules(): ?array<string, ValidationRule> — returns the keyed map when each([...]) was set, null otherwise (including list-form state).ArrayRule::getEachListRule(): ?ValidationRule — returns the list-form rule when each(VR) was set, null otherwise (including keyed-form state).Both narrow getters are mutually exclusive per the 1.23.0 storage refactor — at most one returns non-null for a given ArrayRule.
ArrayRule::getEachRules()getEachRules() is marked [@deprecated](https://github.com/deprecated) 1.24.0 pointing at the two narrow getters. Planned for 1.25.0: return type narrows to ?array<string, ValidationRule> — the ValidationRule branch of the union drops. The method itself stays (still useful for the keyed case), but list-form retrieval moves exclusively to getEachListRule().
Two-release deprecation window gives downstream consumers time to migrate — every consumer of getEachRules() that hits the list-form branch gets one minor-release heads-up before the flip. If you currently call getEachRules() and need the ValidationRule branch, migrate to getEachListRule():
// Before
$rules = $arrayRule->getEachRules();
if ($rules instanceof ValidationRule) {
// ... list-form handling
}
// After
if (($listRule = $arrayRule->getEachListRule()) !== null) {
// ... list-form handling
}
Internal callers in the package (RuleSet::flattenRule, FieldRule::buildNestedRules, pre-existing tests) have been migrated to the narrow getters so phpstan-deprecation-rules stays clean for downstream consumers running PHPStan with deprecation checks enabled.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.23.0...1.24.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 225.0ms | 3.2ms | ~71x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2859.3ms | 17.5ms | ~163x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.3ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.3ms | 3.3ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3415.7ms | 68.5ms | ~50x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~6x |
Two new features land: per-item pre-evaluation extends from presence-conditionals to value-conditionals, and two subclass-friendly helpers eliminate the get-wrap-mutate-set boilerplate around each() / children(). Plus a bug fix for a latent validator-cache collision that the new pre-evaluation path surfaced, and a bug fix for an empty-array fallback in compileFluentRules().
Covers required_if, required_unless, prohibited_if, prohibited_unless. For wildcard items, the new ValueConditionalReducer rewrites active rules to bare required / prohibited (unlocking fast-check) and drops inactive rules before Laravel's validator runs. Rules with custom {field}.{rule_name} messages or validation.custom.* translator overrides survive intact so translator lookups still fire.
// Before: required_if routes through Laravel's slow validator for every item
RuleSet::from([
'users.*.postcode' => FluentRule::field()
->requiredIf('role', 'admin')
->rule('string'),
]);
// After: for each item, reducer rewrites to `required|string` (admin)
// or `string` (non-admin) — remainder fast-checks as usual
Parity with Laravel's validateRequiredIf / validateRequiredUnless / validateProhibitedIf / validateProhibitedUnless is pinned by 32 side-by-side tests covering the four semantic nuances of parseDependentRuleParameters:
required_if's Arr::has short-circuit (dep missing → inactive) vs the other three rules' null-conversion pathconvertValuesToBoolean via shouldConvertToBoolean against the item-local rule setconvertValuesToNull string-to-null coercionin_array (numeric-string "1" matches int 1, but is_bool($other) || is_null($other) switches to strict)Closure / bool-form requiredIf(Closure|bool) (which wraps to Illuminate\Validation\Rules\RequiredIf) flows through unmodified — object rules aren't the reducer's surface.
500 contacts × required_if:role,admin (half admin with postcode, half non-admin without):
| Approach | Time | Speedup |
|---|---|---|
| Native Laravel | 99.5ms | 1x |
| RuleSet (pre-eval + fast-check) | 17.1ms | 5.8x |
each() and children()The subclass-extends-parent FormRequest pattern ("parent shapes, child adds one field") previously forced consumers to write:
$rules = Arr::wrap($parentRule->getEachRules()); // misleading safety
$rules['id'] = FluentRule::numeric()->nullable();
return $parentRule->each($rules); // full-replace
This loses the parent's base constraints on the ArrayRule itself (nullable, max, etc.) unless the child reconstructs them, and the Arr::wrap doesn't actually protect against a list-shaped parent — $rules['id'] = … on a each(FluentRule::string()) state silently produces a malformed [0 => $stringRule, 'id' => $idRule].
Four new helpers — two on ArrayRule, two on FieldRule:
// ArrayRule
$rule->addEachRule(string $key, ValidationRule $rule): static;
$rule->mergeEachRules(array $rules): static;
// FieldRule
$rule->addChildRule(string $key, ValidationRule $rule): static;
$rule->mergeChildRules(array $rules): static;
Contract:
LogicException. Silent override would hide the "parent already defines this" mistake. Use mergeEachRules / mergeChildRules for intentional replacement (later-wins merge).InvalidArgumentException. They'd expand to malformed wildcard paths (items.*.) or dotted paths (parent.).CannotExtendListShapedEach. The list form (each(FluentRule::string())) is terminal — the item IS the scalar, there's no sub-key to add under. The exception message points at the keyed form.FluentRule::array()->nullable()->max(20)->each([…])->addEachRule('id', …) still carries nullable + max:20 in the compiled output. Test-pinned.ArrayRule's eachRules property is now always ?array<string, ValidationRule> (never a union with bare ValidationRule). A separate ?ValidationRule $eachListRule slot carries the list form; setting either via each() clears the other. Public getEachRules() return type (ValidationRule|array<string, ValidationRule>|null) is unchanged for full BC — the reconstruction happens at read time. Makes internal code paths that walk keyed rules free of union-branching.
Pre-existing latent bug, activated by the value-conditional pre-eval path above.
ItemValidator caches Laravel\Validator instances across items with the same effective rule shape. The cache key was just implode(',', array_keys($rules)) — fine as long as two items with the same field set always had the same rule content. The reducers (both presence and value) can break that assumption: for a chain like required_if:role,admin|exists:users,id, admin items reduce to required|exists:users,id while non-admin items reduce to just exists:users,id. Same postcode field, different effective pipe string → cache collision → second item reuses first's immutable Validator and applies the wrong rule chain.
Fixed by routing cache-key generation through a new internal RuleCacheKey::for() that includes string content verbatim for string rules and a stable fingerprint (spl_object_id for objects, gettype for scalars, walked for arrays) for non-string rules. Regression test pins the exact required_if + slow custom rule scenario.
compileFluentRules() honored an explicitly empty rules array$this->validate([]) / validateOnly(..., []) on a Livewire component using HasFluentValidation now correctly means "no validation" instead of silently falling back to the component's rules() default. The previous $rules ? … : … treated [] as falsy and routed to the fallback; changed to $rules !== null ? ….
array_merge(...) inside foreach loops (O(n²) reallocation) replaced with collect-then-merge-once in three sites:
BatchDatabaseChecker::queryValues() — scales with CHUNK_SIZE, the most load-bearing of the three.ArrayRule::buildEachNestedRules() + buildChildNestedRules()FieldRule::buildNestedRules()Plus two new RuleSet helpers — isEmpty() and hasObjectRules() — absorbing the inline "any FluentRule objects in here?" scans that were duplicated across getRules() and compileFluentRules(). HasFluentValidation::resolveFluentRuleSource() now returns a RuleSet directly when rules() yields one, sparing a toArray() / from() round-trip.
| Surface | Laravel 11 | Laravel 12 | Laravel 13 |
|---|---|---|---|
| Value-conditional reducer | ✅ | ✅ | ✅ |
addEachRule / mergeEachRules + FieldRule equivalents |
✅ | ✅ | ✅ |
CannotExtendListShapedEach exception |
✅ | ✅ | ✅ |
RuleSet::isEmpty() / hasObjectRules() |
✅ | ✅ | ✅ |
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.22.0...1.23.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 211.9ms | 3.0ms | ~70x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2814.4ms | 19.2ms | ~147x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.4ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.1ms | 2.9ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3442.5ms | 63.8ms | ~54x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~6x |
Batched database validation now refuses to run unsafe queries. BatchDatabaseChecker pre-populates values from raw input before any per-item rule executes, which previously let a hostile 100k-element payload fire 100 × whereIn(1000) queries and could crash strict databases (PostgreSQL raises invalid input syntax for type integer on malformed values). Three layered guards now short-circuit these paths before a single query fires.
filter → dedup → cap check → queryinteger / numeric / uuid / ulid / string rules on each item drop values that would never pass validation anyway. Hostile input like {"items": [{"id": "abc"}]} for an integer|exists rule no longer reaches the whereIn — the dropped values are caught by the per-item validator with the usual integer error, so end-user error semantics are unchanged.max:N short-circuit. On the HasFluentRules FormRequest path, each concrete wildcard attribute's immediate parent array is inspected for a declared max:N before any DB query. Over-limit input surfaces as a ValidationException on the parent attribute with zero DB queries executed.(table, column, rule-type) group. BatchDatabaseChecker::$maxValuesPerGroup (default 10_000) is a defence-in-depth ceiling. Exceeding it throws the new BatchLimitExceededException, which the documented entry points (HasFluentRules, RuleSet::validate(), RuleSet::check()) remap to the standard ValidationException.// Configuration (override once during boot — mutation at request time is NOT Octane-safe)
SanderMuller\FluentValidation\BatchDatabaseChecker::$maxValuesPerGroup = 50_000;
// Exception for power users who need routing decisions pre-remap
namespace SanderMuller\FluentValidation\Exceptions;
final class BatchLimitExceededException extends \RuntimeException
{
public const REASON_PARENT_MAX = 'parent-max';
public const REASON_HARD_CAP = 'hard-cap';
public function __construct(
public readonly string $table,
public readonly string $column,
public readonly string $ruleType, // 'exists' | 'unique'
public readonly string $reason, // REASON_* constant
public readonly int $valueCount,
public readonly int $limit,
public readonly ?string $attribute = null, // parent path for parent-max, null for hard-cap
) { /* ... */ }
}
// Pre-remap catch for consumers wanting distinct HTTP status per reason
// (e.g. 413 on hard-cap, 422 on parent-max):
try {
$request->validate();
} catch (BatchLimitExceededException $e) {
return $e->reason === BatchLimitExceededException::REASON_HARD_CAP
? response('Payload Too Large', 413)
: response()->json(['error' => 'validation', 'attribute' => $e->attribute], 422);
}
New static helper for filtering raw values by per-item type rule:
BatchDatabaseChecker::filterValuesByType(mixed[] $values, array|string $itemRules): array
exists + unique conflationWhen the same (table, column) carried both an exists and a unique rule in one validator (rare, but legal), registerLookups previously stored both groups under one key — the second addLookup() silently overwrote the first, corrupting validation results. The new conflict detector refuses to batch either group and lets Laravel's fallback DatabasePresenceVerifier handle each rule with correct per-item queries. Small perf hit, correct semantics.
| Vector | Before | After |
|---|---|---|
Attacker POSTs 100k items to array.max:100 + each(id exists) |
100 × whereIn(1000) queries fire, then max fails |
0 queries; ValidationException on parent |
Attacker sends {"items": [{"id": "abc"}, ...]} where id is integer on PostgreSQL |
500 error — invalid input syntax for type integer |
"abc" dropped pre-query; per-item integer error with correct attribute key |
Developer forgets parent max:N, 100k valid items |
Batch runs unbounded | BatchLimitExceededException(reason='hard-cap') — remapped to ValidationException |
Through the documented entry points — HasFluentRules, RuleSet::validate(), RuleSet::check() — consumers still see ValidationException. Nothing observable changes for code that uses those.
Consumers who construct validators directly from $ruleSet->prepare() may now observe a raw BatchLimitExceededException where previously the validator would either fire unbounded queries or raise ValidationException from the eventual max check. This is the escape-hatch path; the raw exception is part of the supported public surface for power users.
max:N on the parent is inspected. size:N, between:a,b, and outer-ancestor maxes in nested-wildcard chains are not — rely on the hard cap for defence-in-depth against those.{"items": {"foo": {...}}}) bypass the parent-max check; the hard cap still applies.failedValidation() does not fire on the parent-max / hard-cap paths. The throw happens inside createDefaultValidator() before the FormRequest-level hook is reachable. The trait remap still converts to ValidationException so global exception handlers see the standard type.Legitimate bulk-import endpoints that need to process more than 10_000 distinct values per (table, column, rule-type) group should raise the cap during boot:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
\SanderMuller\FluentValidation\BatchDatabaseChecker::$maxValuesPerGroup = 50_000;
}
Do NOT mutate the property at request time under Octane / Swoole — it is shared across requests within the same worker.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.21.0...1.22.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 214.5ms | 3.1ms | ~70x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2791.8ms | 17.8ms | ~157x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 29.0ms | 0.9ms | ~31x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.2ms | 2.8ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3395.7ms | 64.6ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~6x |
ArrayRule::contains() and doesntContain() now route through Laravel's Rules\Contains / Rules\DoesntContain object form (when available) and inherit the upstream CSV-quoting + enum resolution. Previously the methods emitted a naive implode(',', $values) pipe-string that silently broke on values containing commas or double-quotes, and the signature rejected BackedEnum / UnitEnum / Arrayable / single-array inputs that Rule::contains() accepts natively.
Strict superset — every existing callsite keeps working:
// Before: only string|int scalars, comma-broken on escapes
FluentRule::array()->contains('php', 'laravel')
// After: same call, plus:
FluentRule::array()->contains(['php', 'laravel']) // single array
FluentRule::array()->contains(collect(['php', 'laravel'])) // any Arrayable
FluentRule::array()->contains(Status::Active) // BackedEnum → value
FluentRule::array()->contains(MyMode::Foo) // UnitEnum → name
FluentRule::array()->contains('he said "hi"', 'has,comma') // CSV-escaped correctly
On Laravel 12+, values are routed through Rules\Contains::__toString() which wraps each value in "..." and doubles-up embedded quotes. On Laravel 11, the new serializeContainsValues() helper applies the same escape rules to the pipe-string fallback. Either way, 'a,b' is no longer split into two separate required-contained values.
Before:
FluentRule::array()->contains('a,b')
// emitted: 'contains:a,b'
// Laravel parses with str_getcsv → ['a', 'b']
// validator now requires BOTH 'a' AND 'b' in the array, not 'a,b'
After:
FluentRule::array()->contains('a,b')
// emitted: 'contains:"a,b"' (L11) or new Contains(['a,b']) (L12+)
// Laravel parses → ['a,b']
// validator correctly requires 'a,b' as a literal value
->message() + messageFor() bind to the correct keyHasFieldModifiers::addRule() now maps Rules\Contains → 'contains' and Rules\DoesntContain → 'doesnt_contain' instead of the class-basename fallback ('contains' / 'doesntContain'). messageFor('doesnt_contain', ...) now resolves as consumers expect.
Two new fail-fast guards that surface misuse clearly instead of corrupting the rule:
->contains(['a'], ['b']) throws InvalidArgumentException('contains()/doesntContain() does not accept multiple array or Arrayable arguments. Pass either a single iterable (->contains($values)) or variadic scalars (->contains($a, $b, $c)).'). Laravel's own Rule::contains(['a'], ['b']) silently ignores the second arg; passing nested arrays to Contains::__toString would crash on str_replace. Clear error beats both failure modes.doesntContain() on Laravel 11: throws RuntimeException('doesntContain() requires Laravel 12+.') at the method call site. validateDoesntContain / Rules\DoesntContain both shipped in L12; prior behavior surfaced as a deep validator-stack error. Matches the existing FluentRule::anyOf() precedent.| Laravel | contains() |
doesntContain() |
|---|---|---|
| 11.x | Pipe-string fallback with CSV escape | RuntimeException (method not available upstream) |
| 12.x / 13.x | Rules\Contains object form |
Rules\DoesntContain object form |
The addRule() match table uses string class-name comparison for Contains / DoesntContain to avoid instanceof triggering an autoload fatal on L11 where those classes don't exist.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.20.0...1.21.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 188.3ms | 2.6ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3160.1ms | 15.0ms | ~210x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 26.5ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 8.6ms | 2.7ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2531.1ms | 57.2ms | ~44x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
Inline message: named argument on every non-variadic rule method and every factory with a stable error-lookup key. Colocate the message with the rule it binds to, without ->message() chain position or ->messageFor('rule', ...) string coupling. Plus a Boost skill that migrates public function messages(): array overrides into the new form.
message: named argRecommended form:
FluentRule::string('Full Name')
->required(message: 'We need your name!')
->min(2, message: 'At least :min characters.')
->max(255)
Available on factories too:
FluentRule::email(message: 'Must be a valid email.')
FluentRule::uuid(message: 'Must be a UUID.')
FluentRule::integer(message: 'Must be a whole number.')
FluentRule::array(message: 'Must be a list.')->required()
| Form | When to use |
|---|---|
->method(…, message: '…') |
Preferred. Colocated with the rule, rename-safe, works on factories and rule methods. |
->method(…)->message('…') |
Shorthand when you want the message on the most recent rule. Binds to $lastConstraint. Works on variadic methods too. |
->messageFor('rule', '…') |
Targets a rule by name at any point in the chain. Required for non-last sub-rules on composite methods, Macroable methods, and custom ->rule(object) calls that need class-basename-keyed messages. |
->messageFor() is not deprecated — it's the documented escape hatch for cases message: can't cover.
->message() now works on factoriesPre-1.20, FluentRule::email()->message('Invalid!') threw a LogicException because the factory's implicit constraint ('email') never flowed through addRule() and $lastConstraint was null. Rule class constructors now seed $lastConstraint to the factory's defining rule name, so ->message() binds correctly without needing messageFor('email', ...):
FluentRule::email()->message('Invalid!') // now binds to 'email'
FluentRule::string()->message('Must be text.') // now binds to 'string'
FluentRule::array()->message('Must be a list.')// now binds to 'array'
Seeded factories: string, numeric, boolean, accepted, declined, file, image, array, email.
The LogicException still fires for truly-empty chains (FluentRule::field()->message(...)) — behaviour narrowed, not removed.
NumericRule::digits(), digitsBetween(), exactly() internally add integer first, then the sized rule. DateRule::between() adds after then before. ImageRule::width() / minWidth() / ratio() all funnel through dimensions(). On these methods, message: binds to the last sub-rule. Target earlier sub-rules via messageFor:
FluentRule::numeric()
->digits(5, message: 'Must be 5 digits.') // binds to 'digits'
->messageFor('integer', 'Must be whole.') // targets the 'integer' sub-rule
FluentRule::date()
->between('2020-01-01', '2026-12-31', message: 'Out of range.') // binds to 'before'
->messageFor('after', 'Must be after start.') // targets 'after'
Docblocks on these methods spell out the binding rule.
message:requiredWith, requiredWithAll, requiredWithout, requiredWithoutAll, presentIf, presentUnless, presentWith, presentWithAll, missingIf, missingUnless, missingWith, missingWithAll, requiredIf, requiredUnless, excludeIf, excludeUnless, prohibitedIf, prohibitedUnless, prohibits, acceptedIf, declinedIf, doesntEndWith, doesntStartWith, endsWith, startsWith, email, extensions, mimes, mimetypes, requiredArrayKeys, contains, doesntContain. PHP forbids params after a variadic. Use ->message() (shorter) or messageFor().addRule() — EmailRule::rfcCompliant, strict, validateMxRecord, preventSpoofing, withNativeValidation; PasswordRule::min, max, letters, mixedCase, numbers, symbols, uncompromised. These configure the embedded rule object; the relevant message target is the factory call (FluentRule::email(message: '...')) or the underlying sub-key (messageFor('password.letters', '...')).FluentRule::date() / ::dateTime() factories — error-lookup key varies at build between 'date' and 'date_format:...'; no deterministic seed possible. Attach messages to a specific method: FluentRule::date()->before('2026-12-31', message: 'Too late.') or use messageFor().FluentRule::password() factory — Password failures use sub-keys (password.mixed, password.letters, password.numbers, etc.) in Laravel 11's message lookup (which lacks the shortRule fallback added in L12). Users target sub-keys via messageFor('password.letters', '…') or a messages(): array entry.FluentRule::field() / ::anyOf() — no implicit constraint to message.migrate-messages-array Boost skillNew skill at resources/boost/skills/migrate-messages-array/ that rewrites FormRequest messages(): array overrides into inline message: form on the matching fluent chain. Classifies each field.rule key into one of three rewrite tiers:
message: on the owning chain method or factory.messageFor — variadic methods, composite sub-rules, ->rule(object) escape. Removes the messages() entry by using messageFor on the chain.messages() with a comment explaining why (factory-emitted implicit rules, dynamic keys, cross-method helpers, Macroable chain methods).12-path skip-log taxonomy covering helper-method extraction, local-variable indirection, ternary / match / spread, Macroable methods, wildcard nesting through each()/children(), ->when() closure hops, and translated-value preservation. Dry-run mode outputs a per-file diff + skip-log table before applying.
Activate via boost:install on consumer apps that pin sandermuller/laravel-fluent-validation ^1.20.
Every new parameter is an optional trailing ?string $message = null. No existing call site shifts semantics. No existing method renamed, removed, or reordered. ->message() and ->messageFor() both stay first-class; neither is deprecated.
Existing messages(): array overrides in consumer FormRequests continue to work — Laravel's native message precedence over message: / ->message() / ->messageFor() is unchanged.
HasFieldModifiers: required, sometimes, filled, present, prohibited, missing, requiredIfAccepted, requiredIfDeclined, prohibitedIfAccepted, prohibitedIfDeclined, rule.HasEmbeddedRules: unique, exists, enum, in, notIn.StringRule: 33 of 38 rule-adding methods (5 variadic skipped).NumericRule: 25 of 25.ArrayRule: 6 of 9 (3 variadic skipped).DateRule: 15 methods (direct + wrappers like beforeToday, past).FileRule: 4 of 7 (3 variadic skipped).ImageRule: 9 dimension wrappers (all funnel through dimensions()).EmailRule: max, confirmed, same, different.PasswordRule: confirmed.BooleanRule: accepted, declined.FieldRule: same, different, confirmed.FluentRule factories: string, numeric, integer, boolean, array, file, image, accepted, declined, email, url, uuid, ulid, ip, ipv4, ipv6, macAddress, json, timezone, hexColor, activeUrl, regex, list.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.19.0...1.20.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 229.4ms | 3.2ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2950.9ms | 18.6ms | ~159x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 31.8ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3416.9ms | 68.6ms | ~50x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
Shorthand factories for the most common single-rule strings, plus a symmetric DeclinedRule and four sign-only helpers for NumericRule. Every addition is strictly additive — no existing rule changed its compiled string.
Each is a thin delegate over FluentRule::string()/array()/field() — reach for the shortcut when the format/type is the only constraint besides presence modifiers:
FluentRule::ipv4() // string()->ipv4()
FluentRule::ipv6() // string()->ipv6()
FluentRule::macAddress() // string()->macAddress()
FluentRule::json() // string()->json()
FluentRule::timezone() // string()->timezone()
FluentRule::hexColor() // string()->hexColor()
FluentRule::activeUrl() // string()->activeUrl()
FluentRule::regex('/^\d+$/') // string()->regex(...)
FluentRule::list() // array()->list()
FluentRule::enum(Status::class) // field()->enum(...)
Each accepts an optional ?string $label (and FluentRule::regex() takes the pattern as the first positional arg, label second) so existing label-bearing chains collapse cleanly:
FluentRule::string('Website')->activeUrl() // before
FluentRule::activeUrl('Website') // after
The enum() shortcut deliberately returns an untyped FieldRule: Laravel's enum validation rule handles both string-backed and int-backed enums, so forcing a string type prefix would surprise int-backed users. Chain FluentRule::string()->enum(...) or FluentRule::integer()->enum(...) when you do want a type constraint alongside.
FluentRule::declined() — symmetric sibling of accepted()DeclinedRule is now a first-class standalone rule, mirroring AcceptedRule one-for-one. Useful for opt-out checkboxes on HTML forms where the input is 'no'/'off'/'0' — values boolean would reject.
FluentRule::declined() // no | off | 0 | '0' | false | 'false'
FluentRule::declined()->declinedIf('under_18', 'yes')
->declinedIf(...) replaces the base declined with declined_if on compile (same logic AcceptedRule::acceptedIf uses), so you never get the impossible declined|declined_if pair.
The footgun note on FluentRule::boolean()->accepted() applies equally here: FluentRule::boolean()->declined() compiles to boolean|declined — boolean rejects 'no'/'off' which declined would otherwise permit. Use FluentRule::declined() when the input shape is HTML-form-ish.
The existing greaterThan(string $field) / greaterThanOrEqualTo(...) / lessThan(...) / lessThanOrEqualTo(...) methods are designed for comparisons against another field. The common "must be positive" / "must be non-negative" case has no field to compare against — it's a literal-zero comparison that Laravel's gt/gte/lt/lte rules accept natively.
Four new methods on NumericRule target exactly that case:
FluentRule::numeric()->positive() // gt:0
FluentRule::numeric()->negative() // lt:0
FluentRule::numeric()->nonNegative() // gte:0
FluentRule::numeric()->nonPositive() // lte:0
No change to the field-comparison methods — use those when you have a field name; use these when you have literal zero.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.18.0...1.19.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 230.4ms | 3.1ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2872.1ms | 19.5ms | ~148x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.5ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.5ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3432.3ms | 69.4ms | ~49x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~8x |
Clearer failure mode when calling type-specific methods on the untyped FluentRule::field() builder, plus an opt-in Pest/PHPUnit arch helper for downstream apps that want belt-and-suspenders coverage.
FluentRule::field()->{typeRule}(...)FluentRule::field() is the untyped builder — it carries no base type constraint. It supports modifiers (required, nullable, present, conditional presence), children(), same/different/confirmed, the embedded-rule factories (exists, unique, enum, in, notIn), and the ->rule(...) escape hatch. What it intentionally does not expose is type-specific rules — min, max, regex, email, digits, mimes, before/after, contains, ipv4, timezone, allowSvg, etc. Those live on the typed builders.
Previously, FluentRule::field()->min(5) passed PHPStan (because Macroable advertises an unbounded __call surface) but fatalled at runtime with Laravel's generic BadMethodCallException: Method min does not exist. — usually inside a controller or queued job, far from where the rule was authored.
As of 1.18.0, FieldRule overrides __call and __callStatic to throw a typed SanderMuller\FluentValidation\Exceptions\UnknownFluentRuleMethod that names the correct typed builder:
UnknownFluentRuleMethod: FluentRule::field() has no method min().
Use `FluentRule::string()`, `FluentRule::numeric()`, `FluentRule::array()`, `FluentRule::file()`, `FluentRule::image()`, or `FluentRule::password()` and chain `->min(...)`.
The hint table (SanderMuller\FluentValidation\Exceptions\TypedBuilderHint) is derived by reflection from every public method on every typed builder (StringRule, NumericRule, DateRule, ArrayRule, FileRule, ImageRule, BooleanRule, AcceptedRule, PasswordRule, EmailRule) minus FieldRule's own public surface. New methods added to any typed builder in future releases are automatically covered — no hand-maintained list to drift.
A few hints are hand-curated because they either redirect to a documented footgun-free alternative or flag Laravel rule-string names that the fluent API renames:
accepted → FluentRule::accepted() (not FluentRule::boolean()->accepted(), which rejects 'yes'/'on').size → ->exactly(...) on a typed builder.gt/gte/lt/lte → ->greaterThan/greaterThanOrEqualTo/lessThan/lessThanOrEqualTo on FluentRule::numeric().alphaNum → FluentRule::string()->alphaNumeric(...).contains → FluentRule::array()->contains(...) (not string()).The new exception extends BadMethodCallException, so any try { } catch (BadMethodCallException) continues to work — only the message text changes. Registered macros on FieldRule still dispatch (the override preserves Macroable-compatible semantics before throwing).
BansFieldRuleTypeMethodsFor downstream apps that want to fail at test time rather than rely on the runtime exception, the package now ships SanderMuller\FluentValidation\Testing\Arch\BansFieldRuleTypeMethods. It walks configured paths with nikic/php-parser, runs a NameResolver pass so imports and aliases resolve to the fully qualified class name, and returns every file containing a FluentRule::field() chain whose next method is in the typed-builder hint table.
use SanderMuller\FluentValidation\Testing\Arch\BansFieldRuleTypeMethods;
arch('FluentRule::field() does not chain type-specific methods')
->expect(BansFieldRuleTypeMethods::scope('app/'))
->toBeEmpty();
Because name resolution is FQN-based, use SanderMuller\FluentValidation\FluentRule as Rule; Rule::field()->min(5) is caught, while an unrelated Acme\FluentRule::field()->min(5) in a different namespace is not. The banned method set is the same reflection-derived list the runtime exception uses, so coverage is identical across both layers.
nikic/php-parser is listed under suggest in composer.json, pinned to ^5.0 — the package itself remains dependency-light. The helper raises a clear RuntimeException with install instructions when the parser is absent, and a separate \Error-catching path gives a versioned upgrade message when an older ^4.x install is detected (v4 lacks ParserFactory::createForHostVersion()).
composer require --dev "nikic/php-parser:^5.0"
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.17.1...1.18.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 234.8ms | 3.2ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2990.4ms | 20.7ms | ~144x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.8ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.6ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3429.5ms | 70.0ms | ~49x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
Architecture hardening patch. Four independent moves to reduce complexity debt and catch upstream breakage early:
FastCheckCompiler split into per-family compilers under src/FastCheck/.RuleSet into src/Internal/ItemValidator.php + collaborators.FluentRulesTester promoted to [@api](https://github.com/api) — the package's stable test surface.laravel/framework dev-master for early warning on breaking changes.No behavior change, no breaking public API changes. Every existing consumer continues working untouched.
FluentRulesTester is now [@api](https://github.com/api) — stable under semverThe tester has shipped for several releases and stabilized. As of 1.17.1, every public method on SanderMuller\FluentValidation\Testing\FluentRulesTester carries an [@api](https://github.com/api) PHPDoc tag; signatures are now locked under semver. The tester remains the package's sole recommended test surface — everything else under Testing/ stays [@internal](https://github.com/internal).
use SanderMuller\FluentValidation\Testing\FluentRulesTester;
// Raw FluentRule chain
FluentRulesTester::for(FluentRule::string()->required()->min(3))
->with(['value' => 'hi'])
->fails();
// RuleSet instance
FluentRulesTester::for(
RuleSet::make()->field('email', FluentRule::email()->required())
)->with(['email' => 'a@b.test'])->passes();
// FluentFormRequest subclass — runs the full pipeline including authorize()
FluentRulesTester::for(UpdateVideoRequest::class)
->withRoute(['video' => $video])
->actingAs($user)
->with(['title' => 'Updated'])
->passes();
// FluentValidator subclass
FluentRulesTester::for(JsonImportValidator::class, $user, 'sku-')
->with($payload)
->passes();
See README#testing-fluent-rules for the full surface: passes(), fails(), failsWith(), failsOnly(), failsWithAny(), failsWithMessage(), doesNotFailOn(), assertUnauthorized(), errors(), validated(), plus Livewire support (set(), call(), andCall(), mount()) and Pest expectations (toPassWith, toFailOn, toBeFluentRuleOf).
FastCheckCompiler splitsrc/FastCheckCompiler.php kept its FQCN and public surface (3 static methods: compile(), compileWithItemContext(), compileWithPresenceConditionals()) but is now a thin dispatcher. Per-family compilers live under src/FastCheck/ (CoreValueCompiler, ItemContextCompiler, PresenceConditionalCompiler, ProhibitedCompiler) plus shared utilities (LaravelEmptiness, ItemAwareBranchBuilder). Each family compiler is final + [@internal](https://github.com/internal). Public API unchanged — no consumer action required.
Dispatch order inside compile(): CoreValueCompiler first (hot path), ProhibitedCompiler second. Benchmarks across 3 runs match the pre-split baseline within ±3% on all 6 scenarios (product import, nested order lines, event scheduling, article submission, conditional import, login form). Both orderings benchmarked — no perf delta outside noise.
Minor expansion of fast-check coverage. prohibited|sometimes and prohibited|bail (and orderings thereof) now compile to a fast-check closure — previously these took the Laravel slow path because the old monolithic parse() treated sometimes as non-fast-checkable. Verdict is identical against native Laravel (covered by ProhibitedConditionalParityTest); purely a speedup for rule strings that mix those modifiers.
ItemValidator extracted from RuleSetThe per-item validation loop (validateItems) plus its nine supporting helpers (analyzeConditionals, reduceRulesForItem, stripConditionalTuples, findCommonDispatchField, ruleCacheKey, buildFastChecks, buildBatchVerifier, passesAllFastChecks, collectErrors) relocated from RuleSet into src/Internal/:
ItemRuleCompiler — rule-shape concerns.ItemErrorCollector — fast-check run + error harvest.ItemValidator — the loop itself.All three final + [@internal](https://github.com/internal). RuleSet::validateItems() shrank to a 2-line delegate. Class cognitive complexity on RuleSet dropped 52% (274 → 131). Benchmarks within ±3% of baseline.
dev-master CI legNew .github/workflows/laravel-dev-master.yml runs the test suite against laravel/framework:dev-master + orchestra/testbench:dev-master on PHP 8.4 every day at 06:00 UTC. On failure, a single tracking issue is opened (reused on subsequent failures via comment). Exists to catch Laravel breaking changes — rule-string parser changes, Validator::$customMessages removal, Rule::in()/exists()/unique() shape changes — before they ship as tagged releases.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.17.0...1.17.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 230.0ms | 3.2ms | ~71x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2865.7ms | 17.7ms | ~162x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.0ms | 1.0ms | ~31x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3556.5ms | 68.7ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~8x |
One feature: every rule class now implements SanderMuller\FluentValidation\Contracts\FluentRuleContract — a single stable return type for rules() arrays. Mijntp's ask; usable across any downstream app with type-aware tooling.
FluentRuleContract — single return type for rules() arraysBefore 1.17, typing a rules() method's return value meant either enumerating every concrete rule class ([@return](https://github.com/return) array<string, FieldRule|StringRule|NumericRule|EmailRule|…>) — which grows unwieldy and churns on every new rule type — or falling back to Laravel's ValidationRule (works, but doesn't distinguish fluent-package rules from arbitrary rule objects).
1.17 adds a dedicated contract that every shipped rule class implements:
use SanderMuller\FluentValidation\Contracts\FluentRuleContract;
/** [@return](https://github.com/return) array<string, FluentRuleContract> */
public function rules(): array
{
return [
'name' => FluentRule::string()->required()->min(2),
'email' => FluentRule::email()->required()->unique('users'),
'age' => FluentRule::numeric()->nullable()->integer()->min(0),
];
}
One type covers the whole package. FluentRuleContract extends Illuminate\Contracts\Validation\ValidationRule, so any code currently typed against Laravel's native contract keeps working unchanged.
Per mijntp's design steer, the contract carries the full universally-shared surface — every HasFieldModifiers modifier (required(), nullable(), bail(), prohibited()), every conditional (requiredIf(), requiredWith(), excludeUnless(), the full list), every metadata method (label(), message(), getLabel(), getCustomMessages()), plus SelfValidates plumbing (compiledRules(), canCompile(), buildNestedRules(), toArray()) and Laravel's Conditionable chain (when() / unless()).
Type-specific methods (StringRule::email(), NumericRule::integer(), ImageRule::dimensions(), etc.) intentionally stay on the concrete class — narrow to the concrete type when you need to call them.
All chain-returning methods return static, so concrete subclasses keep their own type when you narrow.
AcceptedRule, ArrayRule, BooleanRule, DateRule, EmailRule, FieldRule, FileRule, ImageRule (inherited via FileRule), NumericRule, PasswordRule, StringRule.
Tests at tests/FluentRuleContractTest.php include a runtime reflection audit that catches drift — any new rule class added to Rules/* without FluentRuleContract in its implements list fails the guard.
withBag() docs follow-upUnrelated docs cleanup: the README's "Using with validateWithBag" section (last touched before 1.16.0) documented the old prepare() + Validator::make(...) + validateWithBag(...) incantation. Now updated to show the 1.16.0 RuleSet::from($rules)->withBag($name)->validate($input) chain directly.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.16.0...1.17.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 232.1ms | 3.2ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2970.2ms | 18.2ms | ~163x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.0ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3504.4ms | 68.9ms | ~51x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
Two small features and one correctness fix:
FluentRule::field()->prohibited() now fast-checks.RuleSet::withBag(string $name) for Fortify-style named error bags.prohibited_with* family I was going to build doesn't exist in Laravel; what did ship is a carefully narrowed bare-prohibited fast-check with full parity coverage.FluentRule::field()->prohibited() is fast-checkableUntil 1.16, prohibited() always routed through Laravel's validator. Now a rule that consists of just prohibited (optionally with nullable / sometimes) compiles to a PHP closure that short-circuits at the FastCheckCompiler level, identical to how required has always worked.
// Before 1.16: slow-path, full Laravel validator dispatch per item.
// 1.16+: fast-checked PHP closure, no validator invocation when value is empty.
FluentRule::field()->prohibited()
Closure uses the same shared isLaravelEmpty helper that drives the presence-conditional reducer: passes on null / '' / [] / whitespace-only string / empty Countable / File with empty path; fails on anything non-empty.
Scope caveat — important. The fast-check only activates for prohibited alone (possibly with nullable / sometimes). Combined with any other rule (e.g. prohibited|string|max:10), the rule slow-paths through Laravel. Reason: the closure receives the item's value as a single argument and can't distinguish "value is explicitly null" from "value is absent from the item". Laravel treats these differently (non-implicit rules like string run on explicit null but skip on absent), and the closure can't reproduce the distinction without violating fast-check parity. Bare prohibited is still the common shape; combinations stay on the existing (correct) slow path.
Parity tests at tests/ProhibitedConditionalParityTest.php pin the fast-check verdict against native Laravel across the full shape grid, plus all the contradictory-combination cases (prohibited|required, prohibited|accepted, prohibited|declined), item-aware combinations (prohibited|same:other, prohibited|different:other, prohibited|after:start), and the File-with-empty-path edge case.
RuleSet::withBag(string $name) — named error bagsMirrors Laravel's Validator::validateWithBag($name). Motivation from downstream Fortify usage: when multiple forms share a page (update-password, reset-password, profile-update), each needs its own error bag so their messages don't collide in shared Blade partials. Before 1.16 RuleSet::validate() threw straight into the default bag, forcing consumers back to the manual Validator::make() incantation and defeating RuleSet as the canonical entry point.
// Before:
$p = RuleSet::from($rules)->prepare($input);
Validator::make($input, $p->rules, $p->messages, $p->attributes)
->validateWithBag('updatePassword');
// 1.16:
RuleSet::from($rules)->withBag('updatePassword')->validate($input);
Chains with stopOnFirstFailure(), failOnUnknownFields(), and every other existing toggle. Only affects the thrown ValidationException's errorBag property — check() is unaffected since it never throws. The wildcard-group path is also covered by a single try/catch around the internal pipeline.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.15.1...1.16.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 211.3ms | 3.1ms | ~67x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2798.0ms | 18.2ms | ~154x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.3ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.0ms | 2.8ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3451.2ms | 62.7ms | ~55x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
Internal cleanup / latent-bug patch. PHPStan baseline paid down aggressively; RuleSet shed its presence-conditional code path to a new [@internal](https://github.com/internal) sibling class. Zero user-visible behavior change on the happy path — same rules, same errors, same benchmark numbers. A few type-guard tightenings swap latent runtime-throw paths for silent skip on malformed input (see "Latent bugs fixed" below).
Every minor release since 1.11 added to phpstan-baseline.neon rather than subtracting from it. Over five releases the baseline grew from tight to 114 entries / 343 lines, hiding real signals behind accumulated compromises. 1.16 reverses it:
| Metric | 1.15.0 | 1.15.1 |
|---|---|---|
| Baseline entries | 114 | 14 |
| Baseline lines | 343 | 85 |
typeCoverage.paramTypeCoverage rows |
5 | 0 |
pest.redundantExpectation rows |
18 | 0 |
argument.type rows |
12 | 0 |
cast.string / binaryOp.invalid / nullCoalesce.offset / missingType.iterableValue / varTag.* / assign.propertyType / method.internal* |
9 combined | 0 |
Inline [@phpstan-ignore](https://github.com/phpstan-ignore) comments |
0 | 3 (documented) |
Three real latent paths were hiding behind the baseline, each promoted to the patch line rather than buried in cleanup:
FastCheckCompiler::sizePair — when called with a type flag (numeric|gt:ref, string|gt:ref, array|gt:ref) and the target $value didn't match the flag, the helper would call count($value) or mb_strlen((string) $value) on the wrong shape. In practice $value was caller-guaranteed by an earlier rule in the pipe chain, but the guarantee was implicit — the helper is now defensive (is_numeric($value) / is_string($value) / is_array($value) before size computation). Returns null when the shapes disagree; the caller treats null as "comparison not applicable", which is the semantically correct fallback.
BatchDatabaseChecker::uniqueStringValues — previously strval(...) on every element of an arbitrary array<mixed>. strval throws TypeError on arrays and on objects without __toString. In the DB-batching hot path this couldn't easily reach if your DB driver only returned scalars, but a custom presence-verifier or an exotic column type would have triggered it. Replaced with a guarded closure that coerces unknown shapes to empty string.
PrecomputedPresenceVerifier::flip — (string) $v on non-scalar values silently produced "Array" or threw on toString-less objects. Added a scalar/Stringable guard; unknown shapes are now skipped rather than producing stringified garbage in the lookup map.
None of these is a crash anyone has reported against current Laravel + common DB drivers — they're defensive-coding hardenings that PHPStan correctly flagged and that are now robust against malformed input.
PresenceConditionalReducer extracted from RuleSetThe ~300-line presence-conditional pre-evaluation code that landed in 1.15 has been lifted to a new SanderMuller\FluentValidation\PresenceConditionalReducer sibling class (marked [@internal](https://github.com/internal), final, static-only). RuleSet::validateItems now delegates via two static calls (hasAny, apply) — zero behavior change, but RuleSet's class-level cognitive complexity dropped below the 80 threshold.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.15.0...1.15.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 189.8ms | 2.6ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3156.5ms | 16.0ms | ~197x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 26.8ms | 0.9ms | ~31x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 8.6ms | 2.7ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2557.9ms | 57.0ms | ~45x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~8x |
Pre-evaluates presence-conditional rules (required_with, required_without, required_with_all, required_without_all) per item inside wildcard groups, unlocking fast-check for a shape Laravel previously forced onto the slow path.
Wildcard validation has always gone fast when every rule on a field was fast-checkable. Presence conditionals were a gap: FastCheckCompiler::compileWithPresenceConditionals fast-checked simple dependent field names, but rejected dotted dependent paths like required_without:profile.birthdate at its identifier regex. Anyone whose payload shape nested the dependent under a parent (profile.birthdate, address.postcode, etc.) paid the slow-path tax per item.
1.15 adds a reducer step in RuleSet::reduceRulesForItem that evaluates the presence conditional against the item, then rewrites:
required — fast-checkable by the existing compiler, with the remainder of the rule chain intact.// Before 1.15: fell through to native Laravel because the dependent field
// has a dot in it.
'addresses' => FluentRule::array()->each([
'postcode' => FluentRule::field()
->requiredWithout('profile.birthdate')
->rule('string'),
]),
// In 1.15: the reducer resolves `profile.birthdate` per item, rewrites the
// rule, and the postcode field goes down the fast-check path.
Benchmark (tests/SlowPathBenchTest.php, 500 contacts × wildcard rule with required_without:profile.birthdate):
Data shape mixes active and inactive items to exercise both reducer paths. No speedup notch change on any existing hot-path benchmark scenario vs 1.14.0.
The rewrite is load-bearing for anyone who translates validation messages per-rule-name. If you have 'postcode.required_without' => 'Vul a.u.b. uw postcode in' in a FormRequest messages() method, or validation.custom.addresses.*.postcode.required_without in a translator file, the reducer detects the override and keeps the original rule string intact. The rewrite-to-required path only engages when no user override exists, so the generic required message firing there is the correct outcome (no required_without message was ever configured).
Detection covers:
$messages map key equal to {field}.{rule} or ending with .{field}.{rule} — matches bare-field, wildcard-prefixed, and parent-prefixed forms FormRequests typically emit.validation.custom.{field}.{rule} via direct translator lookup.validation.custom (e.g. validation.custom.addresses.*.postcode.required_without) via Laravel-equivalent Str::is matching.The first-pass implementation used explode(',', $rawParam). A Codex adversarial review caught that Laravel's ValidationRuleParser::parseParameters uses str_getcsv with CSV semantics — meaning stray leading/trailing commas, CSV-quoted fields, and empty-param degenerate forms would silently diverge. Fixed by adopting str_getcsv exactly, relaxing the parameter-slot type to ?string (to mirror Laravel's null-slot → full-item resolution), and deferring entirely-empty raw params back to Laravel. Parity tests cover each edge case.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.14.0...1.15.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 232.5ms | 3.3ms | ~71x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2973.2ms | 18.6ms | ~160x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.3ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.5ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3451.6ms | 69.0ms | ~50x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~8x |
Adds a standalone FluentRule::accepted() factory for the permissive opt-in family, sidestepping a subtle footgun when pairing with the strict boolean rule.
FluentRule::accepted() — permissive opt-in without booleanLaravel's accepted and boolean rules cover overlapping but non-equivalent shapes:
boolean accepts true, false, 1, 0, '1', '0' only. Strict.accepted accepts true, 1, '1', 'yes', 'on', 'true'. Permissive — tuned for HTML form checkboxes.Chaining them with FluentRule::boolean()->accepted() compiles to boolean|accepted, which quietly rejects the 'yes' and 'on' values that checkbox form posts actually deliver — boolean vetoes them before accepted gets a say.
The new factory lets you express the permissive rule without the conflicting base:
// Before — strict base fights permissive rule
'agree' => FluentRule::boolean()->accepted(), // rejects 'yes' / 'on'
// 1.14 — permissive rule, no conflicting base
'agree' => FluentRule::accepted(), // accepts 'yes' / 'on' / '1' / 1 / true / 'true'
Conditional variant also available — it replaces the unconditional base (so FluentRule::accepted()->required()->acceptedIf('role', 'admin') preserves required but drops the unconditional accepted):
'agree' => FluentRule::accepted()->acceptedIf('role', 'admin'),
The existing FluentRule::boolean()->accepted() chain is unchanged (still compiles to boolean|accepted) — the new factory is purely additive. README rule-reference, the shipped fluent-validation boost skill, and the migration-pattern table all document the footgun explicitly so downstream migrations pick the right factory first time.
Laravel's accepted rule is strict-comparison against the accept list. 'YES', 'On', 'True' all fail. Test coverage pins this.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.13.2...1.14.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 210.7ms | 3.0ms | ~69x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2767.4ms | 17.6ms | ~158x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.1ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.0ms | 2.8ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3289.5ms | 62.4ms | ~53x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
Three patches surfaced within hours of 1.13.1 going live as downstream consumers started porting. Hotfix over 1.13.1.
actingAs() covers Livewire component targetsBefore 1.13.2, ->actingAs($user) was silently a no-op on Livewire class-string targets — runLivewire() never read the bound user, so auth()->user() inside mount(), actions, and policy gates returned null. Surfaces as Call to a member function isSuspended() on null-style crashes in auth-aware mount() methods, or silent validation-skip when authorize() gates throw.
Fix: lifted the user-binding into a shared applyActingAs() helper, called from both runFormRequest() and runLivewire(). The two target paths now have symmetrical auth behavior.
FluentRulesTester::for(AppealPage::class)
->actingAs($user)
->set('type', 'refund')
->call('submit')
->passes();
The workaround consumers used on 1.13.1 (calling $this->actingAs($user) on the TestCase outside the tester chain) still works — the in-chain form is now just the documented path.
app.key set in Testbench env for CI Livewire runsEvery Livewire test in 1.13.1's CI failed with No application encryption key has been specified. Livewire's Testable::call() renders a Blade view under the hood, and Laravel's view encryption requires app.key. Local dev envs usually have APP_KEY set (via host app .env); Testbench's default CI env does not.
Fix: tests/TestCase::defineEnvironment() now sets a deterministic test-only app.key. Idempotent for local runs that already have a key configured.
This was strictly a package-CI bug — downstream consumers running the tester inside their own app never hit it (their APP_KEY is set). The release was tagged before CI went green because the local gauntlet passed; /pre-release now has a new step 7 that gates tag on CI-green to prevent this class of env-shape issue.
1.13.0 framed the class-string target as canonical for all Livewire validation tests. Not quite: it's specifically for component-flow dispatch tests (drive state, dispatch action, assert validation fires). Rules-only shape-assert tests should pass $component->rules() or a RuleSet and use ->with($data), not ->call(...).
README "Livewire components" section now opens with an explicit two-shape decision table so downstream migrations pick the right target first time.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.13.1...1.13.2
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 210.3ms | 3.0ms | ~69x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2805.6ms | 17.4ms | ~161x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.5ms | 0.9ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 9.9ms | 2.7ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3344.8ms | 62.8ms | ~53x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
Hotfix: 1.13.0's composer.json shipped with a broken repositories entry
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.13.0...1.13.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 227.1ms | 3.2ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2838.1ms | 17.7ms | ~161x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.1ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.3ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3420.6ms | 67.6ms | ~51x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
The tester-surface release. Four additions driven by a peer-feedback round across mijntp, hihaho, and collectiq as 1.12.x rolled out. Every feature landed with direct downstream validation before tag.
FluentRulesTester::for(SomeLivewireComponent::class) auto-detects Livewire Component subclasses and routes through Livewire::test() so the full submit() flow runs — guard clauses, addError() branches, computed state, rate-limit gates — not just the rule set in isolation.
use SanderMuller\FluentValidation\Testing\FluentRulesTester;
FluentRulesTester::for(AppealPage::class)
->set('type', 'refund')
->set('reason', 'Order arrived damaged in transit.')
->call('submit')
->passes();
set($key, $value) and set([$key => $value]) both work (Livewire-parity). call(...) queues an action; multiple call() / andCall() invocations dispatch in append order against one Livewire::test() instance, so state mutations from action 1 persist into action 2:
FluentRulesTester::for(ImportInteractionsModal::class)
->set('video', $targetVideo)
->call('selectVideo', $sourceVideo->uuid)
->andCall('import')
->failsWith('selectedInteractionIds', 'required');
andCall() is a readability alias for call() — both append to the queue.
Error-bag capture covers both $this->validate()-driven failures AND manual $this->addError(...) calls. Pre-validate guards that return before validate() runs AND post-validate addError (quota checks, external-connectivity branches) both surface via the standard failsWith() / failsWithAny() / failsOnly() assertions.
State is consumed per dispatch — after one chain resolves, the accumulated with() / set() / call() state clears so reused testers don't leak prior cycles into new ones. Codex-flagged during adversarial review; fixed with regression coverage before this tag.
livewire/livewire is a soft dev dep: the Livewire branch class_exists-guards on \Livewire\Component, so PHPUnit-only suites without Livewire installed see the standard "unsupported target" LogicException instead of a hard fatal at autoload time.
failsWithAny($prefix)Inclusive prefix match — the error bag has an error matching $prefix exact OR any dotted descendant ($prefix.*). Useful for "did this subtree fail at all?" assertions:
FluentRulesTester::for(OfflineSyncRequest::class)
->with($payload)
->failsWithAny('actions.0.payload'); // matches actions.0.payload OR actions.0.payload.stars OR …
Substring-match explicitly rejected — failsWithAny('payload') does NOT match someOther.payload.x. For substring or regex matching, use errors() directly.
failsOnly($field, $rule = null) / doesNotFailOn(...$fields)Sharper alternatives to fails(). failsOnly requires exactly one matching error key — surgical regression detection that fails loudly when an unrelated field also broke. doesNotFailOn is the dual: assert specific fields did not fail without enumerating the expected failures.
->failsOnly('email', 'required'); // raises if any other field also failed
->fails()->doesNotFailOn('email', 'name'); // these passed even though others failed
Wildcard-expanded error keys are fully qualified (items.0.name, items.1.name). failsOnly('items.0.name') requires exactly one matching key. For "any item failed" use failsWithAny('items').
RuleSet::modify($field, fn ($rule))Read-modify-write helper for single-field rule transformations. Clones the stored rule before passing to the callback so mutations through chain methods like ->rule(new X) don't bleed back to prior captures of the original. Throws LogicException on missing key — use put() to add new fields.
RuleSet::from($rules)
->modify('email', fn (FluentRule $rule) => $rule->rule(new AllowedEducationEmail()));
Replaces the defensive-clone-at-call-site pattern hihaho flagged in UpdateQuestionRequest post-1.12.x migration.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.3...1.13.0
Four ergonomics fixes on RuleSet and the trait surface, all driven by the hihaho post-Fortify migration audit. None breaking; each is purely additive or documentation-only.
RuleSet implements IteratorAggregateSpread support on RuleSet — [...$ruleSet, 'extra' => $rule] works without an explicit ->toArray() call, matching the Collection / Arrayable sibling shape.
return [
...CreateNewUser::validationRules()->only(['email', 'password']),
'extra' => FluentRule::string()->required(),
];
Hihaho's audit found 12+ [...parent::rules(), ...] spread sites that 1.12.2 forced into ->put() chains + terminal ->toArray(). This restores the natural pattern.
RuleSet::all() alias of toArray()Two devs in one downstream audit hit ->all() independently from Collection muscle memory. Aliasing is friction-free vs the alternative of throwing BadMethodCallException with a helpful pointer:
$ruleSet->all(); // Collection-style, returns array<string, mixed>
$ruleSet->toArray(); // existing — same behavior
HasFluentRules + HasFluentValidation auto-unwrap RuleSet from rules()Both traits now accept either a plain array or a RuleSet from rules():
public function rules(): RuleSet
{
return CreateNewUser::validationRules()
->only(['email', 'password'])
->put('email_confirmation', FluentRule::email()->required()->same('email'));
}
No more terminal ->toArray(). This eliminates the ->toArray() papercut at every FormRequest / Livewire component composing with RuleSet-returning helpers, and removes a real footgun: the hihaho audit caught one ->all() (Collection leftover) on a method that would have returned a Collection mid-validation pipeline and 500'd every live registration if tests hadn't caught it. Auto-unwrap means the only thing the trait sees is what reaches Laravel's validator.
The chokepoint is local — HasFluentRules::createDefaultValidator() and HasFluentValidation::resolveFluentRuleSource() each gain one branch ($rules instanceof RuleSet ? $rules->toArray() : $rules). No Laravel-pipeline surgery; nothing existing in the wild relies on rules() returning RuleSet through the validator (it would have TypeError'd at RuleSet::from(array) immediately).
FluentRule::rule() docblock — mutates receiverOne-line clarification that ->rule(...) mutates the receiver and returns it. Important when chaining off a rule plucked via RuleSet::get() — the appended rule persists on the stored instance, no defensive copy. Clone first if you need isolation:
(clone $ruleSet->get('email'))->rule(new AllowedEducationEmail());
RuleSet interface additions (IteratorAggregate, all()) are purely additive.rules() would have TypeError'd in 1.12.2; nothing in the wild can have depended on it).FluentRule::rule().Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.2...1.12.3
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 226.9ms | 3.2ms | ~71x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2925.4ms | 17.7ms | ~166x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 31.7ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.3ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3459.3ms | 67.2ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~8x |
Three additions to FluentRulesTester and one ergonomics fix on RuleSet, all driven by real-world friction the hihaho fleet hit on the first day of 1.12.1 adoption.
FluentRulesTester::withRoute()Bind route parameters that the FormRequest reads via $this->route(name) inside authorize() or rules(). Without this, FormRequests doing ownership checks (Gate::allows(Policy::ACTION, $this->route('video'))) or conditional rule lookups (exists('videos', 'id', fn ($r) => $r->where('container_id', $this->video()->id))) couldn't be tested at all — they would dereference null and fatal.
FluentRulesTester::for(UpdateVideoRequest::class)
->withRoute(['video' => $video])
->with(['title' => 'New title'])
->passes();
Inside the FormRequest:
$this->route('video') returns the bound $video$this->route('video', $default) returns $video (default ignored when key present)$this->route('missing', $default) returns $defaultRe-callable; later calls fully replace earlier parameters (matches with()).
FluentRulesTester::actingAs()Mirrors Laravel's actingAs($user, $guard = null) test helper. Sets the authenticated user that $this->user() returns inside authorize() and rules(), scoped to a fluent chain rather than the test class.
FluentRulesTester::for(UpdateVideoRequest::class)
->actingAs($user)
->with(['title' => 'New title'])
->passes();
Composes with withRoute() for the common case of route + user gates:
FluentRulesTester::for(UpdateVideoRequest::class)
->withRoute(['video' => $video])
->actingAs($user)
->with(['title' => 'New title'])
->passes();
RuleSet::only() / except() accept array formBoth methods now accept either variadic strings or a single array, matching Collection::only, Collection::except, Arr::only, and Arr::except:
$ruleSet->only('name', 'email'); // variadic — already worked in 1.12.1
$ruleSet->only(['name', 'email']); // array — now also works
The 1.12.1 variadic-only signature was a footgun against the muscle memory built on the rest of the Laravel ecosystem; the first hihaho consumer of 1.12.1 hit TypeError: Argument #1 must be of type string, array given immediately on ->only([...]). Purely additive widening.
failsWith() docblock noteFluentRule::integer() compiles to numeric|integer, and a non-numeric input fails as Numeric (Laravel evaluates numeric first), not as Integer. The case-insensitive Studly lookup happens against Laravel's actual rule-bag keys, not the FluentRule method name. Documented inline so the surprise lands at the docblock instead of in a test failure.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.1...1.12.2
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 165.1ms | 2.5ms | ~67x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2157.5ms | 14.0ms | ~154x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 22.1ms | 0.7ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 7.9ms | 2.2ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2623.5ms | 49.5ms | ~53x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~6x |
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.0...1.12.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 163.9ms | 2.4ms | ~68x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2175.0ms | 13.6ms | ~160x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 22.0ms | 0.7ms | ~30x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 7.8ms | 2.2ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2596.9ms | 48.4ms | ~54x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
A first-class testing surface for fluent rules. FluentRulesTester replaces the per-app validateRules() reinventions and the postJson(...)->assertJsonValidationErrors(...) boilerplate that downstream consumers (mijntp, hihaho, collectiq) all hit when trying to unit-test their FluentRule chains, RuleSets, FormRequests, and FluentValidator subclasses without standing up the HTTP kernel or Livewire harness.
Plus four Collection-style additions to RuleSet (only, except, put, get) and an opt-in Pest expectations file.
use SanderMuller\FluentValidation\Testing\FluentRulesTester;
// 1. Array of rules
FluentRulesTester::for([
'email' => FluentRule::email()->required(),
])->with(['email' => 'a@b.test'])->passes();
// 2. RuleSet instance
FluentRulesTester::for(
RuleSet::make()->field('name', FluentRule::string()->required()->min(2))
)->with(['name' => 'Ada'])->passes();
// 3. Single FluentRule (wrapped under "value" key)
FluentRulesTester::for(FluentRule::string()->required()->min(3))
->with(['value' => 'hi'])
->fails();
// 4. FormRequest class-string — runs the full FormRequest pipeline,
// including authorize(). Call actingAs() before the tester to set
// the user that authorize() sees.
FluentRulesTester::for(StorePostRequest::class)
->with(['title' => 'Hello', 'body' => 'World'])
->failsWith('body', 'min');
// 5. FluentValidator class-string — variadic args after `for(...)`
// forward to the FluentValidator subclass constructor after `$data`,
// mirroring `new MyValidator($data, $user, $prefix)`.
FluentRulesTester::for(JsonImportValidator::class, $user, 'sku-')
->with($payload)
->passes();
with(array $data) is required before any assertion or escape hatch — calling them sooner raises LogicException. with() is re-callable, so a single tester can validate multiple data sets without rebuilding.
| Method | Asserts |
|---|---|
passes() |
underlying validation passed |
fails() |
underlying validation failed |
failsWith($field) |
MessageBag::has($field) |
failsWith($field, $rule) |
Validator::failed() shows $field failed Str::studly($rule) (so 'required' and 'Required' both work) |
failsWithMessage($field, $key, $replacements = []) |
errors()->first($field) === __($key, $replacements) — replaces the assertJsonValidationErrors([... => [__('validation.x', [...])]]) pattern |
assertUnauthorized() |
recorded AuthorizationException (not rethrown) |
errors() |
underlying MessageBag |
validated() |
validated array (throws ValidationException on failure) |
The FormRequest path mirrors what Laravel's own form-request resolver does: instantiate via createFrom(), set container + redirector + user resolver, call validateResolved() in try/catch. ValidationException and AuthorizationException are recorded into a Validated DTO instead of rethrown — so tests assert on outcomes rather than wrapping in their own try/catch.
The user resolver is wired to the auth guard, so $this->user() inside authorize() honours actingAs($user) calls made before invoking the tester.
Three opt-in expectations live in src/Testing/PestExpectations.php. Consumers require_once from their tests/Pest.php to register them:
// tests/Pest.php
require_once __DIR__ . '/../vendor/sandermuller/laravel-fluent-validation/src/Testing/PestExpectations.php';
expect($rules)->toPassWith(['email' => 'a@b.test']);
expect($rules)->toFailOn(['email' => ''], 'email', 'required');
expect(FluentRule::string()->required())->toBeFluentRuleOf(StringRule::class);
The file class_exists-guards on Pest\Expectation and short-circuits when Pest is unavailable, so it's safe to load under PHPUnit-only suites too.
Four Collection-style methods on RuleSet, matching the existing field() / merge() mutate-and-return-self pattern:
$ruleSet->only('name', 'email'); // keep only the named fields
$ruleSet->except('age'); // drop the named fields
$ruleSet->put('name', $rule); // add or replace a single field's rule (alias of field())
$ruleSet->get('name', $default = null); // read a stored rule (uncompiled), or $default if absent
get() returns the raw stored value (FluentRule object, string, array — whatever was stored), uncompiled and unexpanded.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.11.0...1.12.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 229.2ms | 3.2ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2885.3ms | 18.8ms | ~153x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.3ms | 1.0ms | ~33x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.3ms | 3.1ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3444.5ms | 66.9ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
Presence-conditional rules join the item-aware fast-check family. required_with, required_without, required_with_all, and required_without_all now bypass Laravel's validator for wildcard items that satisfy the condition, with full composition against the same / different / confirmed / date / gt / gte / lt / lte field-ref rules landed in 1.10.0.
| Rule | Trigger |
|---|---|
required_with:a,b,... |
ANY listed field present → target required |
required_without:a,b,... |
ANY listed field absent → target required |
required_with_all:a,b,... |
ALL listed fields present → target required |
required_without_all:a,b,... |
ALL listed fields absent → target required |
Multi-param supported for every variant. "Present" matches Laravel's validateRequired exactly — not null, not whitespace-only string (trim() === ''), not empty array, not empty Countable.
Presence conditionals now compose with the item-aware rules from 1.10.0 into a single fast closure:
RuleSet::from([
'orders' => FluentRule::array()->required()->each([
'trigger' => FluentRule::string()->nullable(),
'confirm' => FluentRule::string()->rule('required_with:trigger|same:trigger'),
'min_qty' => FluentRule::numeric()->required(),
'qty' => FluentRule::numeric()->rule('required_without:fallback|gt:min_qty'),
'start' => FluentRule::date()->nullable(),
'end' => FluentRule::date()->rule('required_without_all:start|after:start'),
]),
])->validate($data);
Before 1.11.0, any of those combinations would silently fall through to Laravel's validator because the presence compiler only accepted value-only remainders. 1.11.0's compiler delegates stripped remainders through compileWithItemContext so combinations keep all of their speedup.
Isolated harness (1000 items × 7 fields × 3 presence-conditional rules):
| Version | Optimized | Speedup vs Laravel |
|---|---|---|
| 1.10.0 (slow path) | ~100.6ms | 1x (full Laravel) |
| 1.11.0 (fast-check) | ~7ms | ~14x |
benchmark.php --ci vs 1.10.0 across two clean runs: Product −7% / −10%, Nested −15% / −16%, Event/Article/Conditional within noise. No regressions.
One new public method on FastCheckCompiler:
public static function compileWithPresenceConditionals(string $ruleString): ?\Closure
Returns ?\Closure(mixed $value, array<string, mixed> $item): bool. The closure evaluates the presence condition(s) against the item at call time, then either (a) fails fast if the target is required but empty, or (b) runs the stripped-remainder closure. RuleSet::buildFastChecks picks this up automatically as a third fallback after compile() and compileWithItemContext() — existing call sites benefit without code changes.
The same adversarial loop that shipped 1.10.0 caught two drift patterns in the first implementation — both fixed before release:
Presence definition was too strict. The initial check used ! in_array($raw, [null, '', []], true), which treats ' ' and empty Countable as present. Laravel's validateRequired treats them as empty. Fixed by centralizing on a new isLaravelEmpty() helper matching Laravel exactly: null, trim() === '' for strings, empty array, empty Countable.
No composition with item-aware remainders. The first cut compiled the stripped remainder via value-only compile(), so required_with:trigger|same:other fell to slow path. The second pass added buildItemAwareBranch() which prefers compileWithItemContext (handles same / different / date-ref / gt/gte/lt/lte) and wraps value-only rules to the item-aware signature.
Full parity coverage:
| Grid | Assertions |
|---|---|
| Flat value rules | 720 |
| Item-aware date field-refs | 117 |
| Item-aware same/different | 88 |
| Item-aware confirmed | 7 |
| Item-aware gt/gte/lt/lte | 272 |
| Item-aware presence conditionals | 44 |
| Item-aware presence composition | 8 |
Total: 1,256 parity assertions against Validator::make(...)->passes().
The closure receives null both for "item key missing" and "item key present with null value" because RuleSet passes $item[$field] ?? null. Laravel's presentOrRuleIsImplicit distinguishes these via Arr::has; the closure can't. For non-implicit remainders (e.g. same:other), this means a genuinely absent target may fail the fast path where Laravel would skip. The RuleSet::buildFastChecks wrapper absorbs this: when the fast path rejects, Laravel's validator re-evaluates the item and produces the correct verdict. End-to-end validation behavior is identical; only the fast-check path is a touch stricter than strictly necessary.
FastCheckCompiler::compile() and compileWithItemContext() signatures unchanged.compileWithPresenceConditionals() is additive.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.10.0...1.11.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 189.4ms | 2.6ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 3203.5ms | 15.0ms | ~213x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 26.9ms | 0.9ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 8.6ms | 2.7ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 2532.1ms | 57.0ms | ~44x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.1ms | 0.0ms | ~7x |
Item-aware fast-check for cross-field rules. Wildcard items with date-sibling, equality-sibling, or size-sibling comparisons now skip Laravel's validator when values pass — same mechanism as RuleSet::validate's existing fast-check, extended to rules that reference another field in the same item.
Previously these rules always fell through to Laravel because they need to read a sibling field at validation time:
| Rule family | Examples |
|---|---|
| Date field-ref | after:start_date, before:start_date, after_or_equal:X, before_or_equal:X, date_equals:X |
| Equality | same:FIELD, different:FIELD (single-param only) |
| Confirmation | confirmed, confirmed:custom_field |
| Size comparison (with type flag) | numeric|gt:min_price, string|gt:other, array|gte:baseline, integer|lte:stock, etc. |
All of them now resolve the sibling field via a new closure variant at validation time, matching Laravel's semantics including:
nullable vs required short-circuitspresentOrRuleIsImplicit empty-string skipisSameType constraint for gt/gte/lt/lte (rejects type-mismatched refs)=== / !== for same / differentconfirmed rewrite to same:${attribute}_confirmationRules that still fall through to Laravel:
gt / gte / lt / lte without a type flag (string, array, numeric, or integer)date_format:X + date field-ref (Laravel's format-aware parsing + lenient missing-ref handling can't be matched by a simple closure)different:a,b,cdistinct, exists/unique with closure callbacksEvent scheduling (benchmark.php — 100 events × 3 date-with-sibling-ref rules):
| Version | Optimized | Speedup |
|---|---|---|
| 1.9.1 | 10.4ms | ~2x |
| 1.10.0 | 0.7ms | ~29x |
All other benchmark.php scenarios: within ±5% of 1.9.1 (noise). DB-batching scenarios (--group=benchmark): unchanged.
One new public method on FastCheckCompiler:
public static function compileWithItemContext(
string $ruleString,
?string $attributeName = null,
): ?\Closure
Returns ?\Closure(mixed $value, array<string, mixed> $item): bool. The closure resolves field references like after:FIELD, same:FIELD, gt:FIELD against the passed item array. Passing $attributeName is required for confirmed rule rewriting (the confirmation field name depends on the attribute being validated).
RuleSet::buildFastChecks uses this method as a fallback when the standard compile() call returns null, so existing call sites pick up the speedup automatically — no user code changes required.
Three parity grids assert the fast-check closure's verdict matches Validator::make(...)->passes() for every supported rule across edge-case values:
| Grid | Rules × items | Assertions |
|---|---|---|
| Flat value rules | 40 × 18 | 720 |
| Item-aware date field-refs | 13 × 9 | 117 |
| Item-aware same/different | 8 × 11 | 88 |
| Item-aware confirmed | 7 cases | 7 |
| Item-aware gt/gte/lt/lte | 16 × 17 | 272 |
Total: 1204 parity assertions. An adversarial code review by OpenAI Codex caught two drift patterns during development, both fixed:
null/empty-string short-circuit was too broad for equality rules. Fixed by capturing $nullable and $hasImplicit in the closure and matching Laravel's skip semantics precisely.date_format + date field-ref bypassed the attribute's format. Fixed by bailing compileWithItemContext to the slow path when both are present — Laravel's checkDateTimeOrder has format-aware parsing and lenient missing-ref behavior that strtotime() can't match.RuleSet::validate integration testsNew end-to-end tests assert the fast-check path actually rejects bad data (not just that the closure is correct in isolation):
after:sibling / before:sibling pass/fail pathsafter:a|before:b (dual-gate)same:password / different:username match/mismatchconfirmed (default and custom suffix) match/mismatch/missingnumeric|lte:stock, string|gt:short, combined gt:min|lt:max.ai/skills/pre-release/) gained a docs-freshness audit step: the skill now checks README + .ai/ skills and guidelines for staleness before a release..ai/guidelines/release-automation.md) documents that CHANGELOG.md and the benchmark-table section of release bodies are updated automatically by CI — not manually in the release PR.RepeatedOrEqualToInArrayRector skipped for src/FastCheckCompiler.php to preserve the inlined === null || === '' || === [] presence gate that keeps the hot path allocation-free.FastCheckCompiler::compile() signature and semantics unchanged.$attributeName); existing callers unaffected.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.9.2...1.10.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 229.8ms | 3.2ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2868.5ms | 17.4ms | ~164x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.0ms | 1.0ms | ~32x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.4ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3416.5ms | 68.3ms | ~50x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
Before 1.9.2, rules like after:start_date or before:start_date inside a wildcard each() block always fell through to Laravel's validator. The fast-check compiler bailed the moment it saw a non-literal date param, so validation walked through $validator->passes() once per item.
1.9.2 adds a new closure variant that resolves sibling field references at call time, keeping these validations in the fast path.
RuleSet::from([
'events' => FluentRule::array()->required()->each([
'name' => FluentRule::string()->required()->min(3)->max(255),
'start_date' => FluentRule::date()->required()->after('2025-01-01'),
'end_date' => FluentRule::date()->required()->after('start_date'),
'registration_deadline' => FluentRule::date()->required()->before('start_date'),
]),
])->validate($data);
For 100 events with the rule set above, the optimized path used to invoke Laravel's validator 300 times (3 date-field-ref rules × 100 items). It now runs entirely in PHP closures.
| Metric | 1.9.1 | 1.9.2 | Δ |
|---|---|---|---|
| Median execution time | 10.20ms | 0.65ms | −94% |
benchmark.php --ci, two clean runs)| Scenario | 1.9.0 | 1.9.2 run 1 | 1.9.2 run 2 |
|---|---|---|---|
| Event scheduling (field-ref dates) | 10.4ms / ~2x | 0.7ms / ~29x | 0.7ms / ~27x |
All other scenarios are within noise vs 1.9.0 (-28% to +1%).
New public method on FastCheckCompiler:
public static function compileWithItemContext(string $ruleString): ?\Closure
Returns ?\Closure(mixed $value, array<string, mixed> $item): bool. The closure receives the current value and the wildcard item, resolving date field references like after:FIELD, before:FIELD, after_or_equal:FIELD, before_or_equal:FIELD, and date_equals:FIELD against $item[FIELD].
RuleSet::buildFastChecks uses this method as a fallback when the standard compile() call returns null, so existing call sites pick up the speedup automatically — no code changes required.
after:FIELDafter_or_equal:FIELDbefore:FIELDbefore_or_equal:FIELDdate_equals:FIELDOther field-referenced comparison rules (e.g. gt:FIELD, lt:FIELD) still fall through to Laravel — they can be added the same way if demand warrants it.
A new item-aware parity grid (tests/FastCheckParityTest.php) asserts that the field-ref closure verdict matches Validator::make(...)->passes() across 6 rules × 9 item shapes (54 new assertions, 792 total).
The grid surfaced one Laravel quirk worth documenting: when the referenced field can't be resolved to a valid timestamp (null, missing, empty, unparseable), Laravel treats its value as 0 in the comparison — so after:bad_ref with a valid current date silently passes, while before:bad_ref / before_or_equal:bad_ref / date_equals:bad_ref silently fail. The resolveRefTimestamp() helper matches this behavior exactly.
compileWithItemContext is pre-filtered to only re-parse rules that actually contain after:, before:, or date_equals:. Without this filter, every slow rule paid for a redundant second parse — the Conditional import benchmark briefly drifted +19% before the filter was added. With the filter in place, all non-Event-scheduling scenarios are within noise vs 1.9.1.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.9.1...1.9.2
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 223.4ms | 3.1ms | ~72x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2939.0ms | 17.4ms | ~169x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 31.5ms | 1.0ms | ~33x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.2ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3485.2ms | 67.3ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~7x |
1.9.0's FastCheckCompiler parity rewrite split the closure body into three static helper methods (applyPresenceGates, passesSizeCheck, passesInAndRegexChecks) and used in_array($v, [null, '', []], true) for the presence gate. Each fast-check invocation paid for three static method dispatches plus a literal-array allocation.
On wildcard-heavy payloads this added up fast. Measured against 1.8.2:
| Scenario | 1.8.2 | 1.9.0 | Regression |
|---|---|---|---|
| Product import (500 items) | 3.1ms | 4.0ms | +29% |
| Nested order lines (1000 × 5) | 17.6ms | 25.2ms | +43% |
| Conditional import (100 items) | 57.3ms | 69.6ms | +21% |
1.9.1 inlines the three helpers back into the main closure body and replaces the in_array presence gate with explicit === null || === '' || === [] comparisons. No allocations in the hot path, no extra dispatches.
| Scenario | 1.9.0 | 1.9.1 | Δ |
|---|---|---|---|
| Product import | 4.0ms | 2.9–3.1ms | −11% to −17% |
| Nested order lines | 25.2ms | 15.4–15.7ms | −20% to −22% |
| Event scheduling | 20.9ms | 9.5–9.7ms | ~flat (variance) |
| Article submission | 3.3ms | 2.2–2.3ms | −4% to 0% |
| Conditional import | 69.6ms | 46.6–47.8ms | ~flat to −33% |
| Login form | 0.0ms | 0.0ms | unchanged |
1.9.1 matches or beats 1.8.2 across the board. Nested wildcard scenarios see the biggest recovery since they invoke the fast-check closure thousands of times per validation.
The full parity suite (tests/FastCheckParityTest.php, 738 assertions across 36 rules × 18 edge values) still passes. This change is a pure refactor of the closure body — semantics are unchanged, and the grid that caught 1.9.0's 75 drifts catches any regression from inlining.
rector.php: skip RepeatedOrEqualToInArrayRector for src/FastCheckCompiler.php. The rule otherwise rewrites the inlined === presence gate back to in_array([null, '', []], true), silently reintroducing the per-call array allocation.pre-release skill (.ai/skills/pre-release/) runs Rector, Pint, tests, PHPStan, and both benchmark harnesses (benchmark.php --ci + pest --group=benchmark) to catch regressions like this one before a release is cut.Drop-in replacement for 1.9.0. No behavioral changes to validation output.
Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.9.0...1.9.1
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 230.8ms | 3.2ms | ~73x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2994.6ms | 19.1ms | ~157x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.4ms | 20.6ms | ~2x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.5ms | 3.2ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3447.1ms | 66.8ms | ~52x |
| Login form — 3 fields, no wildcards | Fast-check (flat) | 0.2ms | 0.0ms | ~8x |
RuleSet::check() and the Validated result objectRuleSet::check() validates without throwing. It returns an immutable Validated object you can inspect — useful for import rows, batch jobs, and anywhere a thrown ValidationException would be the wrong control flow.
$result = RuleSet::from($rules)->check($data);
if ($result->fails()) {
Log::warning('row rejected', $result->errors()->all());
return null;
}
$validated = $result->validated(); // array
$safe = $result->safe(); // Illuminate\Support\ValidatedInput
passes(): boolfails(): boolerrors(): MessageBagfirstError(string $field): ?stringvalidated(): array — throws ValidationException if validation failedsafe(): ValidatedInput — throws ValidationException if validation failed. Gives you ->only(), ->except(), ->collect().validator(): ValidatorContract — escape hatch for deep Laravel integration (->after(), ->sometimes(), extensions)check() runs the same internal validation engine as validate() — same fast-check closures, same wildcard expansion, same batched DB queries. There is no double-parse; the result object just wraps the outcome.
Previously, fast-check closures only short-circuited validation for wildcard rules (FluentRule::array()->each(...)). Flat top-level rules went through Laravel's validator unconditionally.
Now, flat rule sets like the one below skip Laravel entirely when values pass:
RuleSet::from([
'name' => 'string|max:255',
'email' => 'email',
'age' => 'integer|min:18',
])->validate($data);
Dotted rule keys (from children()) continue to go through Laravel, since nested lookup and validated-data shaping are still Laravel's responsibility.
A new parity test suite (tests/FastCheckParityTest.php) asserts the fast-check closure's verdict matches Validator::make(...)->passes() for every supported rule across 18 edge values (null, '', [], 0, '0', booleans, scalars, type mismatches, etc.). 738 parity assertions.
Running this suite against the previous implementation surfaced 75 drifts. All are now fixed:
is_string(null) === false), matching Laravel.nullable bypasses null only when no implicit rule (required/accepted/declined) must still run. nullable|accepted with null now correctly fails.sometimes is no longer fast-checkable. The closure can't distinguish absent from present-null without presence tracking; marking it non-fast-checkable avoids silent acceptance of {field: null} where Laravel would fail.'', matching Laravel's presentOrRuleIsImplicit behavior. Previously the fast path failed '' + string|min:2 while Laravel passed.required now fails on empty arrays ([]), matching Laravel.array|min:N / array|max:N enforce count()-based size checks.min/max) require an explicit type flag (string/array/numeric/integer). Without one, the rule is non-fast-checkable — Laravel infers size from runtime type, so the fast path defers.alpha, alpha_dash, alpha_num now accept int/float via string cast (matching Laravel), but still reject bool/null/array.regex and not_regex require is_string($v) || is_numeric($v) (matching Laravel). Booleans, nulls, and arrays fail both rules. Previously, not_regex on an array silently passed.in / not_in reject non-scalars.filled no longer fast-checkableDistinguishing absent from present-null requires presence tracking. Rules containing filled now go through Laravel.
RuleSet::check() exposes the underlying validator via ->validator() for deep Laravel integration.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.8.2...1.9.0
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 231.4ms | 4.0ms | ~58x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2993.2ms | 25.2ms | ~119x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 32.6ms | 20.9ms | ~2x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.6ms | 3.3ms | ~3x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3451.4ms | 69.6ms | ~50x |
| Login form — 3 fields, no wildcards | None | 0.2ms | 0.0ms | ~8x |
[@return](https://github.com/return) array<string, array<mixed>> PHPDoc to RuleSet::compileToArrays(). Resolves PHPStan errors on call sites like $this->validate(RuleSet::compileToArrays(...)) where the return type was inferred as plain array.Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.8.1...1.8.2
| Scenario | Optimizations | Native Laravel | Optimized | Speedup |
|---|---|---|---|---|
| Product import — 500 items, simple rules | Wildcard, fast-check | 213.2ms | 3.1ms | ~68x |
| Nested order lines — 1000 orders × 5 line items | Wildcard, fast-check (nested) | 2810.1ms | 17.6ms | ~160x |
| Event scheduling — 100 items, field-ref dates | Wildcard, partial fast-check | 28.8ms | 17.0ms | ~2x |
| Article submission — 50 items, custom Rule objects | Wildcard only | 10.2ms | 2.8ms | ~4x |
| Conditional import — 100 items, 47 conditional fields | Wildcard, pre-evaluation | 3348.5ms | 57.3ms | ~58x |
| Login form — 3 fields, no wildcards | None | 0.1ms | 0.1ms | ~1x |
How can I help you explore Laravel packages today?