Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Fluent Validation Laravel Package

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).

View on GitHub
Deep Wiki
Context7
1.29.0

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.

Behaviour change

Malformed wildcard rule keys now throw instead of being silently skipped

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.*).

Internal

  • Added characterization/regression tests pinning the behaviour of each wildcard key shape.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.28.1...1.29.0


Benchmark results

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
1.28.1

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.

Performance

Memoized fast-check compilation on the sibling-conditional path

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.

Internal

  • Added an 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


Benchmark results

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
1.28.0

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.

What changed

Laravel 11 support dropped

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.

AI-authoring tooling moved to boost 1.x

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.

Compatibility

  • PHP 8.2 / 8.3 / 8.4
  • Laravel 12 / 13 (prefer-lowest + prefer-stable)
  • ubuntu-latest + windows-latest

No runtime code touched. No public-API change. No new runtime dependencies.

Upgrading

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


Benchmark results

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
1.27.3

Patch fix: the always-on FluentRule guideline never reached consumers' AI tooling because the wrong file extension made boost-core skip it.

What changed

Core guideline renamed core.blade.phpcore.md

The 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.

Upgrading

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


Benchmark results

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
1.27.2

Patch fix: conditional-required and presence modifiers combined with nullable() were silently dropped when a FluentRule self-validates.

What changed

nullable() no longer drops conditional-required / presence modifiers in self-validation

When 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:

  • requiredrequired, the string conditionals (requiredIf / requiredUnless / requiredWith / requiredWithout and their _all / _if_accepted / _if_declined variants), and the RequiredIf / RequiredUnless objects.
  • filled
  • presentpresent / presentIf / presentUnless / presentWith / presentWithAll
  • missingmissing / missingIf / missingUnless / missingWith / missingWithAll

Detection 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.

Upgrading

Drop-in. composer update sandermuller/laravel-fluent-validation.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.27.1...1.27.2


Benchmark results

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
1.27.1

1.27.1

Patch fix for a silent gap in the parent-max:N short-circuit on batched-database validation.

What changed

BatchLimitRemap now populates Validator::failed() with the tripped rule

When 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 directly
  • tests/Testing/FluentRulesTesterClassTargetsTest.php + tests/Fixtures/BailMaxEachFluentFormRequest.php — guards the FluentRulesTester::failsWith(..., 'max') consumer surface

No public-API change. No new dependencies. Same compatibility matrix as 1.27.0.

Compatibility

Same matrix as 1.27.0:

  • PHP 8.2 / 8.3 / 8.4
  • Laravel 11 / 12 / 13 (prefer-lowest + prefer-stable)
  • ubuntu-latest + windows-latest

Upgrading

Drop-in. composer update sandermuller/laravel-fluent-validation.


Benchmark results

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
1.27.0

What changed

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.

Drive-by: 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 require

The 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.

Upgrading

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


Benchmark results

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
1.26.0

What changed

Compile-cache for 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:

  • Soft cap of 1024 entries — apps that build rule strings from runtime values (per-tenant 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.
  • Date-comparison rules are NOT cached. 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.

Pre-extracted conditional metadata in 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.

Dead-work removal in the fast-check hot path

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.

What's Changed

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.25.0...1.26.0


Benchmark results

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
1.25.0

New: 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.

New: 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.

Fixed: fast-check no longer skips Laravel for numeric strings under strict

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.

README pass

This release ships a substantial README rewrite, mostly editorial:

  • Usage restructured to lead with the recommended FormRequest + HasFluentRules flow. Bare $request->validate() and Validator::make() demoted to a smaller ### Other contexts subsection at the end.
  • Why this package? subdivided into Fluent / Array notation / Messages & attributes / Performance, with the intro example replaced by harder-to-remember pain points (min:5 polymorphism across types, the unique:users,email,$ignoreId,id slot order, date_equals vs same vs before_or_equal).
  • Performance section now leads with the use-case framing — wins concentrate on CSV/JSON imports, bulk-edit forms, and settings pages; on a 3-field login form the absolute saving is microseconds.
  • Rule reference rewritten one-concept-per-line with // 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()).
  • Comparison table ("Compared to Laravel's Rule class") expanded from 11 rows to 7 grouped sub-tables covering all 25 Illuminate\Validation\Rule:: static methods plus the FluentRule-only additions.
  • Labels gets a [!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.
  • Troubleshooting tightened. The 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.
  • Rector and PHPStan companion sections trimmed. Setup, set list, configuration constants, verification workflow, and skip-log diagnostics now live only in those packages' READMEs. The fluent-validation README states the surface and points at the source of truth.
  • TOC sub-links into <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.

What's Changed

New Contributors

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.24.0...1.25.0


Benchmark results

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
1.24.0

New: RuleSet::modifyEach / modifyChildren sugar

The 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 (modifyEachArrayRule; modifyChildrenFieldRule). List-shape each(FluentRule::string()) state propagates CannotExtendListShapedEach through modifyEach, same as the primitive.

New: narrow getters on ArrayRule

ArrayRule::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.

Deprecated: 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


Benchmark results

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
1.23.0

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().

New: per-item pre-evaluation for value-conditional rules

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 path
  • convertValuesToBoolean via shouldConvertToBoolean against the item-local rule set
  • convertValuesToNull string-to-null coercion
  • Loose-vs-strict in_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.

Benchmark

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

New: extend-parent helpers for 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:

  • Collision throws LogicException. Silent override would hide the "parent already defines this" mistake. Use mergeEachRules / mergeChildRules for intentional replacement (later-wins merge).
  • Empty keys throw InvalidArgumentException. They'd expand to malformed wildcard paths (items.*.) or dotted paths (parent.).
  • List-shape state throws 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.
  • Base constraints survive every call. FluentRule::array()->nullable()->max(20)->each([…])->addEachRule('id', …) still carries nullable + max:20 in the compiled output. Test-pinned.

Internal storage refactor

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.

Fix: per-item validator cache collision with varying slow rules

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.

Fix: 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 ? ….

Internal cleanup

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.

Cross-version support

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


Benchmark results

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
1.22.0

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.

Three guards, evaluated in canonical order filter → dedup → cap check → query

  1. Per-item type pre-filter. integer / 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.
  2. Parent 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.
  3. Hard cap per (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.

New public surface

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

Fixed: latent exists + unique conflation

When 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.

Threat matrix

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

Behavioural change (minor-version bump)

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.

Known scope

  • Only 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.
  • Numerically-indexed wildcards only. String-keyed collections ({"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.

Configuration guidance for consumers

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


Benchmark results

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
1.21.0

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.

Widened signature

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

Escaping fixes silent data corruption

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 key

HasFieldModifiers::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.

Runtime guards

Two new fail-fast guards that surface misuse clearly instead of corrupting the rule:

  • Multi-array varargs: ->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.

Cross-version support

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


Benchmark results

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
1.20.0

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.

Inline message: named arg

Recommended 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()

Three forms, three purposes

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 factories

Pre-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.

Composite methods bind to the last sub-rule

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.

Not accepted on message:

  • Variadic-trailing methodsrequiredWith, 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().
  • Mode modifiers that don't call 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 skill

New 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:

  1. Portable — inline message: on the owning chain method or factory.
  2. Portable-via-messageFor — variadic methods, composite sub-rules, ->rule(object) escape. Removes the messages() entry by using messageFor on the chain.
  3. Unportable — stays in 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.

Backwards compatibility

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.

Affected surface

  • 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


Benchmark results

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
1.19.0

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.

New top-level shorthand factories

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|declinedboolean rejects 'no'/'off' which declined would otherwise permit. Use FluentRule::declined() when the input shape is HTML-form-ish.

Numeric sign helpers — hardcoded-zero comparisons

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


Benchmark results

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
1.18.0

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.

Informative runtime exception on 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:

  • acceptedFluentRule::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().
  • alphaNumFluentRule::string()->alphaNumeric(...).
  • containsFluentRule::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).

New opt-in arch helper — BansFieldRuleTypeMethods

For 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


Benchmark results

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
1.17.1

Architecture hardening patch. Four independent moves to reduce complexity debt and catch upstream breakage early:

  1. FastCheckCompiler split into per-family compilers under src/FastCheck/.
  2. Per-item validation loop extracted from RuleSet into src/Internal/ItemValidator.php + collaborators.
  3. FluentRulesTester promoted to [@api](https://github.com/api) — the package's stable test surface.
  4. Nightly CI leg against 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 semver

The 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).

Internal refactor — FastCheckCompiler split

src/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.

Internal refactor — ItemValidator extracted from RuleSet

The 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.

Nightly dev-master CI leg

New .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


Benchmark results

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
1.17.0

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() arrays

Before 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.

Scope: medium contract, not marker-only

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.

11 rule classes implementing the contract

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-up

Unrelated 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


Benchmark results

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
1.16.0

Two small features and one correctness fix:

  1. FluentRule::field()->prohibited() now fast-checks.
  2. RuleSet::withBag(string $name) for Fortify-style named error bags.
  3. Mid-release correctness pivot — original 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-checkable

Until 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 bags

Mirrors 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


Benchmark results

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
1.15.1

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).

PHPStan baseline reduction sprint

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)

Latent bugs fixed

Three real latent paths were hiding behind the baseline, each promoted to the patch line rather than buried in cleanup:

  1. 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.

  2. 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.

  3. 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 RuleSet

The ~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


Benchmark results

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
1.15.0

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.

Presence conditionals via pre-evaluation

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:

  • Active (the rule requires the target): collapse to plain required — fast-checkable by the existing compiler, with the remainder of the rule chain intact.
  • Inactive (the rule does not require the target): drop the rule entirely.
  • Active with a custom user message on the original rule name: keep the original rule intact so the override still fires.
// 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):

  • Native Laravel: 99.2ms
  • RuleSet pre-eval + fast-check: 13.5ms (7.3x)

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.

Custom-message preservation

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:

  • Any $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.
  • Flattened wildcard keys under validation.custom (e.g. validation.custom.addresses.*.postcode.required_without) via Laravel-equivalent Str::is matching.

Parameter parsing matches Laravel exactly

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


Benchmark results

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
1.14.0

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 boolean

Laravel'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.

Case-sensitivity note

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


Benchmark results

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
1.13.2

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 targets

Before 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 runs

Every 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.

README — two distinct Livewire test shapes

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


Benchmark results

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
1.13.1

1.13.1

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


Benchmark results

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
1.13.0

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.

Livewire component target

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

1.12.3

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 IteratorAggregate

Spread 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 receiver

One-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());

No breaking changes

  • RuleSet interface additions (IteratorAggregate, all()) are purely additive.
  • Trait auto-unwrap is purely additive (RuleSet from rules() would have TypeError'd in 1.12.2; nothing in the wild can have depended on it).
  • Docblock-only on FluentRule::rule().
  • Full test suite: 2,033 tests / 2,812 assertions.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.2...1.12.3


Benchmark results

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
1.12.2

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 $default

Re-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 form

Both 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 note

FluentRule::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


Benchmark results

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
1.12.1

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.12.0...1.12.1


Benchmark results

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
1.12.0

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.

FluentRulesTester

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.

Assertions

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)

FormRequest resolution

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.

Optional Pest expectations

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.

RuleSet additions

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


Benchmark results

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
1.11.0

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.

What's fast-checked now

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.

Composition with field-ref rules

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.

Benchmark impact

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.

API

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.

Parity

The same adversarial loop that shipped 1.10.0 caught two drift patterns in the first implementation — both fixed before release:

  1. 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.

  2. 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().

Documented limitation

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.

No breaking changes

  • Existing rule sets keep working without modification.
  • FastCheckCompiler::compile() and compileWithItemContext() signatures unchanged.
  • New compileWithPresenceConditionals() is additive.
  • Full test suite: 1,954 tests / 2,664 assertions.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.10.0...1.11.0


Benchmark results

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
1.10.0

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.

What's fast-checked now

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-circuits
  • presentOrRuleIsImplicit empty-string skip
  • Laravel's loose-coercion behavior for unresolvable date refs (null → 0 in comparisons)
  • Laravel's isSameType constraint for gt/gte/lt/lte (rejects type-mismatched refs)
  • Strict === / !== for same / different
  • confirmed rewrite to same:${attribute}_confirmation

Rules 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)
  • Multi-param different:a,b,c
  • Custom Rule objects, closures, distinct, exists/unique with closure callbacks

Benchmark impact

Event 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.

API

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.

Parity

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:

  • The 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 tests

New end-to-end tests assert the fast-check path actually rejects bad data (not just that the closure is correct in isolation):

  • Date field-ref after:sibling / before:sibling pass/fail paths
  • Combined after:a|before:b (dual-gate)
  • same:password / different:username match/mismatch
  • confirmed (default and custom suffix) match/mismatch/missing
  • numeric|lte:stock, string|gt:short, combined gt:min|lt:max

Other

  • Pre-release skill (.ai/skills/pre-release/) gained a docs-freshness audit step: the skill now checks README + .ai/ skills and guidelines for staleness before a release.
  • Release automation guideline (.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.
  • Rector RepeatedOrEqualToInArrayRector skipped for src/FastCheckCompiler.php to preserve the inlined === null || === '' || === [] presence gate that keeps the hot path allocation-free.

No breaking changes

  • Existing rule sets keep working without modification.
  • FastCheckCompiler::compile() signature and semantics unchanged.
  • Public API gained one optional method parameter ($attributeName); existing callers unaffected.
  • Full test suite: 1895 tests / 2592 assertions.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.9.2...1.10.0


Benchmark results

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
1.9.2

Fast-check date field references (12–16x faster for wildcard date rules)

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.

Benchmark Δ vs 1.9.1 (isolated harness, 100 events × 4 fields)

Metric 1.9.1 1.9.2 Δ
Median execution time 10.20ms 0.65ms −94%

Benchmark Δ vs 1.9.0 (full 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%).

API

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.

Supported rules (field-ref form)

  • after:FIELD
  • after_or_equal:FIELD
  • before:FIELD
  • before_or_equal:FIELD
  • date_equals:FIELD

Other 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.

Parity with Laravel

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.

Pre-filter optimization

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


Benchmark results

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.1

Fix: 1.9.0 hot-path performance regression

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.

Benchmark Δ vs 1.9.0 (two runs)

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.

Parity preserved

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.

Also

  • 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.
  • New 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.

No API changes

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


Benchmark results

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
1.9.0

Errors-as-data: RuleSet::check() and the Validated result object

RuleSet::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

API

  • passes(): bool
  • fails(): bool
  • errors(): MessageBag
  • firstError(string $field): ?string
  • validated(): array — throws ValidationException if validation failed
  • safe(): 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.

Fast-check extended to non-wildcard rules

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.

FastCheckCompiler: Laravel parity rewrite

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:

Null handling

  • Null no longer short-circuits to pass. Rules run against null and typically fail (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.

Empty string

  • Non-implicit rules now skip on '', matching Laravel's presentOrRuleIsImplicit behavior. Previously the fast path failed '' + string|min:2 while Laravel passed.

Array semantics

  • required now fails on empty arrays ([]), matching Laravel.
  • array|min:N / array|max:N enforce count()-based size checks.
  • Size rules (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.

Scalar casts

  • 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-checkable

Distinguishing absent from present-null requires presence tracking. Rules containing filled now go through Laravel.

Other

  • RuleSet::check() exposes the underlying validator via ->validator() for deep Laravel integration.
  • README updated: fast-check documented as working for flat rules, not just wildcards.

Full Changelog: https://github.com/SanderMuller/laravel-fluent-validation/compare/1.8.2...1.9.0


Benchmark results

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
1.8.2

Fix

  • Added [@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


Benchmark results

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
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
directorytree/privacy-filter-classifier
directorytree/privacy-filter
datacore/hub-sdk
develia/commons
cuci/prototurk-sdk
cuci/prototurk-sdk-symfony
develia/geo-bundle
dreamzy/livewire-charts
touchestate-sdk/php-sdk
22h/doctrine-garbage-collection-bundle
agtp/agtp-php
agtp/mod-php
splash/sonata-admin
splash/metadata
splash/openapi
splash/scopes
splash/toolkit
testo/output-teamcity
testo/bridge-symfony
spatie/flare-daemon-runtime