sandermuller/laravel-fluent-validation
Fluent validation rule builders for Laravel: write rules with IDE autocompletion instead of string syntax. Each rule type exposes only applicable methods, each() and children() keep parent and child rules together, and the HasFluentRules trait speeds up wildcard validation.
composer require sandermuller/laravel-fluent-validation
Requires PHP 8.2+ and Laravel 12+. That floor rules it out for older projects — check composer.json before proposing it on a legacy app.
If you use Laravel Boost, publish the bundled AI skills so assistants get the full API reference:
php artisan boost:install
php artisan boost:update # after each package upgrade
Option A, add the trait to an existing form request:
use Illuminate\Foundation\Http\FormRequest;
use SanderMuller\FluentValidation\FluentRule;
use SanderMuller\FluentValidation\HasFluentRules;
class StorePostRequest extends FormRequest
{
use HasFluentRules;
public function rules(): array
{
return [
'title' => FluentRule::string('Title')->required()->min(2)->max(255),
'body' => FluentRule::string()->required(),
];
}
}
Option B, extend the shipped base class:
use SanderMuller\FluentValidation\FluentFormRequest;
class StorePostRequest extends FluentFormRequest
{
public function rules(): array { /* identical */ }
}
The trait is the lower-friction choice: it drops into an existing request hierarchy without re-parenting your class.
Rule-vs-FluentRule comparison table is the fastest way to translate rules you already know.each() and children() — this is the part that changes how you structure rules() day to day.Convert one form request. Pick the request with the most string-concatenated rules — typically a Store or Update request with 10+ fields — and rewrite just its rules() method. Leave messages() and attributes() in place for now if they exist; strip them in a second pass once the labels are inside the builders.
The immediate win is on unique/exists, where positional string syntax is easiest to get wrong:
// Before — which slot holds the ignore ID?
'email' => ['required', 'email', 'unique:users,email,'.$id],
// After
'email' => FluentRule::email('Email')->required()
->unique('users', 'email', fn ($r) => $r->ignore($id)),
attributes() arrayThe first label argument (positional or named) becomes :attribute in messages. There is no separate attributes() method to keep in sync:
'name' => FluentRule::string('Full Name')->required(),
// "The Full Name field is required."
messages() into the rulePer-rule failure copy attaches to the builder. message() targets one rule, messageFor() targets a named rule, and fieldMessage() is the field-level fallback:
FluentRule::email('Email Address')
->required(message: 'We need your :attribute.')
When migrating a request, this is the piece that lets you delete messages() and attributes() entirely — do it in the same commit as the rules() rewrite, or you will be maintaining two sources of truth.
each() for wildcards, children() for fixed keysThis is the pattern that most changes day-to-day structure. Instead of flat dot-notation keys, children live under their parent:
'items' => FluentRule::array()->each([
'id' => FluentRule::integer()->required()->exists('items', 'id'),
'name' => FluentRule::string()->required()->max(255),
]),
Rules of thumb:
each([...]). Keys inside each() describe fields on every item.children([...]). Use for objects with a known shape.array()->each(...) inside another each() for line-item structures.'items.*.name' form still works. Use it when a rule genuinely does not belong to a parent, but do not mix styles for the same subtree.'role' => FluentRule::string()->when(
$isAdmin,
fn ($r) => $r->required()->in(['admin', 'editor']),
),
The callback receives the builder, so you get the same type-restricted autocompletion inside the branch. For conditionals that need the full request payload, whenInput(fn ($input) => ...) defers the branch to validation time — use it when the decision depends on another field's submitted value.
requiredIf, requiredUnless, excludeIf, excludeUnless, prohibitedIf, prohibitedUnless map one-to-one onto the Rule:: equivalents.
unique and exists take a closure for the query builder, replacing unique:users,email,{$id},id:
->unique('users', 'email', fn ($r) => $r->ignore($id))
->exists('roles', 'id', fn ($r) => $r->where('team_id', $teamId))
Inside each(), these batch into a single whereIn instead of one query per item. On a 200-row import that is the difference between a working endpoint and a timeout — reach for each() specifically when the children carry exists/unique.
rules()FluentRule is a static factory, not a base class. For return types and PHPDoc, reference the stable contract:
use SanderMuller\FluentValidation\Contracts\FluentRuleContract;
Enumerating concrete types (StringRule, EmailRule, …) in signatures is a maintenance trap — the contract covers the shared modifier and conditional surface and will not churn.
Two-track approach:
sandermuller/laravel-fluent-validation-rector) automates the mechanical string-to-builder conversion. Run it first.migration-patterns.md reference for the conversions Rector cannot infer, typically conditionals and array restructuring.Run the Rector pass on one request per PR, not the whole codebase. Large mechanical diffs are unreviewable, and any behaviour drift in a 60-field request will not be spotted.
Anything the builder does not model falls through to raw rules via ->rule([...]):
->rule(['can', 'update', $post])
Reach for this for authorization or third-party custom rules. Do not fight the API.
Also available: RuleSet for building, composing, inspecting, and validating rule sets outside a form request — useful when rules are generated at runtime. And FluentRulesTester plus Pest expectations for asserting rule sets in tests.
FluentRule is a factory, not a typeWriting public function rules(): array with FluentRule in a type hint, or function (FluentRule $r) in a closure, will fail. A closure in when() receives a concrete rule class. Type against FluentRuleContract or Laravel's ValidationRule.
Rule for FluentRule wholesaleThe package is deliberately named differently from Illuminate\Validation\Rule so both can coexist without aliasing. That means a half-migrated codebase compiles fine — no import errors will nudge you to finish. Track remaining string rules with a grep, or you will leave the migration 80% done for a year.
HasFluentRules and FluentFormRequest are alternativesAdding the trait to a class that already extends FluentFormRequest is redundant at best. More importantly, if a request extends a project-specific base request, only the trait is available — extending FluentFormRequest would break the hierarchy.
This is a hard floor, not a soft recommendation. On a Laravel 10 or 11 codebase, the package will not install, and there is no fallback branch to pin to.
each() chainsThe companion PHPStan package (sandermuller/laravel-fluent-validation-phpstan) is opt-in and flags unbounded each() chains. Install it in the same PR as your first array validation, not months later:
composer require --dev sandermuller/laravel-fluent-validation-phpstan
Without it, an each() nested two levels deep is trivially written and hard to spot in review.
Plain form requests need nothing beyond the trait. Livewire needs the HasFluentValidation trait; Filament has a documented workaround. Read the README's Livewire section before wiring these up — the setup is not the same as the form-request path, and the failure mode is silent (rules appear not to apply).
The headline numbers — O(n) wildcards, 62–160x on nested and conditional-heavy payloads — apply to large arrays and many fields: imports, bulk edit, settings pages with wildcard collections. On a three-field login form the package is still faster than the native pipeline, but the saving is microseconds and you will not notice.
So:
each() when validating bulk payloads — the batched whereIn for exists/unique is the concrete win.The rule surface is large (type starters, modifiers, conditionals, macros). Autocompletion covers discovery — FluentRule::string() will not offer digits(), FluentRule::date() will not offer mimes() — but for "does this exist", check the README's rule reference before hand-rolling a ->rule([...]) escape hatch. The type restriction is helpful precisely because it means a missing method signals a wrong builder choice, not a missing feature.
When a builder chain silently has no effect, check in this order:
FluentRule::array() that only exists on FluentRule::string() will not be there to call — but a child rule in the wrong slot inside each() will be accepted and ignored.each() children only apply when the parent array key is present. Pair with required() on the parent.when() closures run at build time; whenInput() runs at validation time. If the condition reads $this->input() in the former, it evaluates before the request is bound.messages() entry stale? If you kept the old arrays alongside the new builders, the old copy may still be winning.FluentRulesTester and the Pest expectations exist so you can assert on a rule set without posting a full request. Use this for the conditional branches — covering $isAdmin = true and false via HTTP tests is slow and noisy; asserting the built rule set is not.
How can I help you explore Laravel packages today?