Weave 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

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

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

Two wiring options — pick one, not both

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.

Where to look first

  1. README → Why this package — the Rule-vs-FluentRule comparison table is the fastest way to translate rules you already know.
  2. README → Rule reference — every type, modifier, and conditional in one place. Bookmark it; it is the page you will open most.
  3. README → Array validation with each() and children() — this is the part that changes how you structure rules() day to day.
  4. README → Troubleshooting — read it before you hit a wall, not after.

First use case

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

Implementation Patterns

Labels replace the attributes() array

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

Move messages() into the rule

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

Arrays: each() for wildcards, children() for fixed keys

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

  • Variable-length / wildcard children → each([...]). Keys inside each() describe fields on every item.
  • Fixed-key children → children([...]). Use for objects with a known shape.
  • Nesting works naturally — nest array()->each(...) inside another each() for line-item structures.
  • The flat '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.

Conditionals branch on the builder, not on strings

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

Database rules

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.

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

Migration path

Two-track approach:

  1. Bulk rewrite — the companion Rector package (sandermuller/laravel-fluent-validation-rector) automates the mechanical string-to-builder conversion. Run it first.
  2. Manual cleanup — the README links a 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.

Escape hatches

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.


Gotchas and Tips

FluentRule is a factory, not a type

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

Do not swap Rule for FluentRule wholesale

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

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

PHP 8.2+ / Laravel 12+

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.

Unbounded each() chains

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

Livewire and Filament need extra wiring

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

Performance: measure at scale, do not assume

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:

  • Do reach for each() when validating bulk payloads — the batched whereIn for exists/unique is the concrete win.
  • Do not justify the migration to your team on performance for typical CRUD forms. Justify it on autocompletion and message co-location.
  • Do not paste benchmark numbers into internal docs by hand — they are CI-injected into release bodies between markers and drift. Link to the release instead.

The README is the API reference

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.

Debugging a rule that "does nothing"

When a builder chain silently has no effect, check in this order:

  1. Is the parent typed correctly? A modifier on 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.
  2. Is the field actually reached? each() children only apply when the parent array key is present. Pair with required() on the parent.
  3. Is a conditional evaluating false? 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.
  4. Is a messages() entry stale? If you kept the old arrays alongside the new builders, the old copy may still be winning.

Test rule sets directly

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.

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.
thomas-0816/doctrine-dbal-duckdb
moyisoft/laravel-admin
oxygen/mod-pages
oxygen/mod-import-export
oxygen/mod-media
ksoft/laravel-contentbuilderjs
ourgoldpackages/exchange
mikkimike/laravel-exchange1c
wandesnet/mercadopago-laravel
tsfcorp/email
larafony/core
laravel-enso/currencies
tartan/laravel-online-payment
gam/laravel-sat-catalogs
cloudcreativity/laravel-stripe
abdulelahragih/querybuilder
wedevbr/celcoin-laravel
surazdott/nepali-date
devsquad-cockpit/laravel
coding-socks/laravel-upload-handler