| name | fluent-validation |
| description | FluentRule builders for Laravel validation. Activates when: writing/modifying rules in FormRequest, Livewire component, or Validator; user mentions FluentRule, HasFluentRules, HasFluentValidation, fluent validation. |
Fluent Validation Rules
Do not prompt the user when this skill is loaded. Apply these rules automatically when writing or modifying validation code. This skill provides context, not an interactive command.
When sandermuller/laravel-fluent-validation is installed, use FluentRule for type-safe, fluent validation rule building with IDE autocompletion.
For deeper guidance, read the relevant reference file before implementing:
references/rule-types.md ā complete method reference for all rule types (string, numeric, date, boolean, array, file, image, email, password, field)
references/field-modifiers.md ā shared modifiers: presence, prohibition, exclusion, labels, messages, conditionals, escape hatch
references/migration-patterns.md ā common before/after patterns when converting from string rules. Read this to avoid unnecessary ->rule() escape hatches
references/performance.md ā wildcard optimization, RuleSet API, benchmarks, custom Validator integration
Entry Point
use SanderMuller\FluentValidation\FluentRule;
use SanderMuller\FluentValidation\RuleSet;
use SanderMuller\FluentValidation\Contracts\FluentRuleContract;
Every rule class in Rules/* implements FluentRuleContract (which itself extends Laravel's ValidationRule). Use it as the single return type for rules() instead of enumerating concrete classes:
public function rules(): array
{
return [
'name' => FluentRule::string()->required()->min(2),
'age' => FluentRule::numeric()->nullable()->integer(),
'email' => FluentRule::email()->required(),
];
}
Available Rule Types
| Factory Method | Returns | Base Laravel Rule |
|---|
FluentRule::string('Label?') | StringRule | 'string' |
FluentRule::numeric('Label?') | NumericRule | 'numeric' |
FluentRule::integer('Label?') | NumericRule | 'numeric|integer' (shorthand) |
FluentRule::date('Label?') | DateRule | 'date' |
FluentRule::dateTime('Label?') | DateRule | 'date_format:Y-m-d H:i:s' |
FluentRule::boolean('Label?') | BooleanRule | 'boolean' |
FluentRule::accepted('Label?') | AcceptedRule | 'accepted' (permissive; no boolean base) |
FluentRule::array(keys?, label:?) | ArrayRule | 'array' |
FluentRule::email('Label?') | EmailRule | 'string' + 'email' |
FluentRule::password(min?, label:?) | PasswordRule | 'string' + Password |
FluentRule::file('Label?') | FileRule | 'file' |
FluentRule::image('Label?') | ImageRule | 'image' |
FluentRule::field('Label?') | FieldRule | (no type constraint) |
FluentRule::anyOf([...]) | AnyOf | OR combinator |
All factory methods accept an optional label that replaces :attribute in error messages.
FluentRule::field() is the untyped builder ā use it when no base type constraint applies. It supports modifiers (required, nullable, present, conditional presence), children(), same/different/confirmed, embedded-rule factories (exists, unique, enum, in, notIn), and the ->rule(...) escape hatch. Do not chain type-specific methods (min, max, regex, email, digits, mimes, before/after, contains, etc.) on field() ā those live on the typed builders (string(), numeric(), array(), date(), file(), boolean()). Calling one on field() throws UnknownFluentRuleMethod at runtime with a hint pointing at the correct typed builder. When generating code, pick the typed builder matching the field's type for any rule that constrains the value itself.
Same reasoning applies to the string-escape-hatch form: do not emit FluentRule::field()->rule('min:1'), ->rule('max:...'), ->rule('regex:...'), or any other ->rule('type_rule:...') on field(). It works at runtime but signals the field has a base type that should be encoded with a typed builder ā emit FluentRule::numeric()->min(1) / FluentRule::string()->regex(...) / etc. instead.
Quick Usage
public function rules(): array
{
return [
'name' => FluentRule::string('Full Name')->required()->min(2)->max(255),
'email' => FluentRule::email('Email')->required()->unique('users'),
'age' => FluentRule::numeric('Age')->nullable()->integer()->min(0),
'role' => FluentRule::string()->required()->in(RoleEnum::class),
'tags' => FluentRule::array()->required()->each(FluentRule::string()->max(50)),
'items' => FluentRule::array()->required()->each([
'name' => FluentRule::string('Item Name')->required(),
'qty' => FluentRule::numeric()->required()->integer()->min(1),
]),
'search' => FluentRule::array()->children([
'value' => FluentRule::string()->nullable(),
'regex' => FluentRule::string()->nullable()->in(['true', 'false']),
]),
'avatar' => FluentRule::image()->nullable()->max('2mb'),
'password' => FluentRule::password()->required()->mixedCase()->numbers(),
];
}
Key Patterns
Labels ā replace :attribute in all error messages:
FluentRule::string('Full Name')->required()
Per-rule messages ā preferred form is inline message::
FluentRule::string()->required(message: 'We need this!')->min(2, message: 'Too short.')
Also available: ->method(...)->message('...') (chained shorthand), ->messageFor('rule', '...') (escape hatch for variadic methods, custom rule objects, and targeting non-last sub-rule on composite methods like digits).
Wildcard children (each) ā produces items.*.name:
FluentRule::array()->each([
'name' => FluentRule::string()->required(),
])
Fixed-key children (children) ā produces search.value:
FluentRule::array()->children([
'value' => FluentRule::string()->nullable(),
])
Build-time conditions ā evaluated when building rules:
FluentRule::string()->when($isAdmin, fn ($r) => $r->min(12))
Validation-time conditions ā evaluated with input data:
FluentRule::string()->whenInput(
fn ($input) => $input->role === 'admin',
fn ($r) => $r->required()->min(12),
)
Enum values in in() ā accepts enum class directly:
FluentRule::string()->in(StatusEnum::class)
Fixed-key children (children) on field ā for untyped parents with known sub-keys:
FluentRule::field()->required()->children([
'value' => FluentRule::string()->nullable(),
'regex' => FluentRule::string()->nullable()->in(['true', 'false']),
])
Escape hatch ā any Laravel rule (string, object, array tuple):
FluentRule::string()->rule('email:rfc,dns')
FluentRule::file()->rule(['mimetypes', ...$types])
FluentRule::string()->rule(new MyCustomRule())
Extend a parent's each() / children() shape ā for subclass FormRequests that add one sub-rule to the parent's map. Preserves parent base constraints (nullable, max, required, etc.):
return parent::rules()->modifyEach('answers', [
'id' => FluentRule::numeric()->nullable(),
]);
return parent::rules()->modify('answers', fn (ArrayRule $rule) =>
$rule->addEachRule('id', FluentRule::numeric()->nullable())
);
RuleSet::modifyEach / modifyChildren wrap mergeEachRules / mergeChildRules (later-wins on collision). For strict add-only, use the primitive modify(..., fn ($r) => $r->addEachRule(...)) ā addEachRule / addChildRule throw on existing-key collision. addEachRule / mergeEachRules throw CannotExtendListShapedEach when the parent's each() is list-shaped (each(FluentRule::string()) without keys) ā convert to keyed form first.
Macros ā reusable rule chains registered in a service provider:
NumericRule::macro('percentage', fn () => $this->integer()->min(0)->max(100));
All rule types support macros via Macroable.
Performance (large arrays)
HasFluentRules automatically applies O(n) wildcard expansion, per-attribute fast-checks (25 rules supported), and batched DB validation (exists/unique rules batched into a single whereIn query). Up to 97x faster for simple rules, 10x for mixed rule sets, and Nā1 queries for database rules. Use RuleSet::validate() for inline validation outside FormRequests. See references/performance.md for details.
Livewire Components
See the fluent-validation-livewire skill for full Livewire guidance. Key point: add use HasFluentValidation to Livewire components. Both flat wildcard keys (items.*) and each()/children() work ā the trait handles the expansion.
Testing Fluent Rules
For unit tests of FluentRule chains, RuleSets, FluentFormRequest subclasses, and FluentValidator subclasses, use FluentRulesTester instead of going through postJson() or hand-rolling a validateRules() helper:
use SanderMuller\FluentValidation\Testing\FluentRulesTester;
FluentRulesTester::for($rules)->with($data)->passes();
FluentRulesTester::for(StorePostRequest::class)->with($payload)->failsWith('email', 'email');
FluentRulesTester::for(JsonImportValidator::class, $user)->with($payload)->passes();
FluentRulesTester::for(UpdateVideoRequest::class)
->withRoute(['video' => $video])
->actingAs($user)
->with($payload)
->passes();
FluentRulesTester::for(AppealPage::class)
->set('type', 'refund')
->set('reason', 'Long enough reason.')
->call('openModal')
->andCall('submit')
->passes();
Target-shape cheatsheet:
| Target | Trigger | Notes |
|---|
array<string, mixed> of rules | with($data) | wraps via RuleSet::from($rules)->check($data) |
RuleSet instance | with($data) | direct ->check($data) |
Single ValidationRule | with(['value' => $x]) | wrapped under 'value' key |
FormRequest class-string | with($data) | full validateResolved() pipeline; pair with withRoute() / actingAs() |
FluentValidator class-string | with($data) + variadic ctor args via for(class, ...$args) | |
Livewire Component class-string | set()/with() then call() (+ andCall()) | multi-action queue dispatches against one Livewire::test() instance; each new chain resets state |
More assertions: failsWithAny($prefix) for subtree failures (exact + dotted descendants), failsOnly($field, $rule = null) for surgical single-field failures, doesNotFailOn(...$fields) for negative assertions, failsWithMessage($field, $key, $replacements = []) for rendered-translation matches, assertUnauthorized() for FormRequest authorize() gate failures. RuleSet has only/except/put/get/modify/all() + IteratorAggregate spread support.
with(array $data) is required before any assertion (Livewire targets require call() instead ā validation only runs on action dispatch). Variadic args after the target are forwarded to FluentValidator subclass constructors after $data. withRoute() and actingAs() are re-callable and only meaningful for FormRequest class-string targets. For unauthorized FormRequests, the AuthorizationException is recorded ā assert via ->fails() or ->assertUnauthorized(). FluentRulesTester is the only stable test surface; everything else under Testing\ is @internal.
Optional Pest expectations live in src/Testing/PestExpectations.php ā require_once from tests/Pest.php to opt in to expect($rules)->toPassWith($data), ->toFailOn($data, $field, $rule), ->toBeFluentRuleOf($class).
Custom Validator Subclasses
Extend FluentValidator instead of Validator. Handles the full pipeline automatically:
use SanderMuller\FluentValidation\FluentValidator;
class MyValidator extends FluentValidator
{
public function __construct(array $data) {
parent::__construct($data, $this->buildRules());
}
}
If the class holds its rules in a method that isn't called rules() (e.g. rulesWithoutPrefix() in JSON-import pipelines), mark the method with #[FluentRules] so the migration Rector rules detect it. The attribute is migration-only and has no runtime effect:
use SanderMuller\FluentValidation\FluentRules;
class JsonImportValidator extends FluentValidator
{
#[FluentRules]
public function rulesWithoutPrefix(): array { }
}