RuleSet
RuleSet is the composable, immutable rule container that powers everything outside a form request: inline validation, shared rule libraries, conditional fields, errors-as-data flows. Reach for it whenever the rules are not bound to a single HTTP request — form requests already wrap a RuleSet for you under the hood.
On this page: Building · Composing · Inspecting and exporting · Validating data · Raw Validator · Custom Validators · Compile pipeline · Method reference
Building a rule set
Three equivalent entry points. Pick whichever reads cleaner at the call site — the array form is compact for static rule lists; the make() builder form is friendlier when fields are added conditionally; define() hands you a FluentSchema so you can drop the FluentRule:: prefix.
use SanderMuller\FluentValidation\FluentSchema;
use SanderMuller\FluentValidation\RuleSet;
// From an array
$validated = RuleSet::from([
'name' => FluentRule::string('Full Name')->required()->min(2)->max(255),
'items' => FluentRule::array()->required()->each([
'name' => FluentRule::string()->required()->min(2),
'price' => FluentRule::numeric()->required()->min(0),
]),
'role' => FluentRule::string()->when($isAdmin, fn ($r) => $r->required()->in(['admin', 'editor'])),
'permissions' => FluentRule::array()->when($isAdmin, fn ($r) => $r->required()),
])
->merge($sharedAddressRules)
->validate($request->all());
// Or fluently, with conditional fields and merging
$validated = RuleSet::make()
->field('name', FluentRule::string('Full Name')->required())
->field('items', FluentRule::array()->required()->each([
'name' => FluentRule::string()->required()->min(2),
'price' => FluentRule::numeric()->required()->min(0),
]))
->when($isAdmin, fn (RuleSet $set) => $set
->field('role', FluentRule::string()->required()->in(['admin', 'editor']))
->field('permissions', FluentRule::array()->required())
)
->merge($sharedAddressRules)
->validate($request->all());
// Or with a FluentSchema builder — no `FluentRule::` prefix on each line
$validated = RuleSet::define(fn (FluentSchema $rules) => [
'name' => $rules->string('Full Name')->required()->min(2)->max(255),
'email' => $rules->email()->required(),
'items' => $rules->array()->required()->each([
'name' => $rules->string()->required(),
]),
])->validate($request->all());Composing rule sets
Most non-trivial validation is assembled, not declared in one shot: shared address rules merged in, parent rules sliced down for a child request, a single field tweaked without rewriting the rest. RuleSet exposes three groups of composition tools.
Slice and combine — merge, only, except, put, get. merge() accepts a RuleSet or a plain array; later wins on key collision.
return UserRules::base()
->only(['email', 'password'])
->put('email_confirmation', FluentRule::email()->required()->same('email'));Read-modify-write — modify, modifyEach, modifyChildren. All three clone the existing rule before handing it to your callback so parent rule sets aren't mutated. modify() is the primitive; modifyEach() and modifyChildren() are sugar for the common case of extending a keyed each([...]) (wildcard arrays) or children([...]) (fixed-key objects) map. The "Extending parent rules in child form requests" section walks through the parent/child inheritance flow with modify/modifyEach. The same shape works for modifyChildren on a fixed-key object:
// Parent
return RuleSet::from([
'address' => FluentRule::field()->required()->children([
'street' => FluentRule::string()->required(),
'city' => FluentRule::string()->required(),
]),
]);
// Child — later-wins merge into the children() map
return parent::rules()->modifyChildren('address', [
'postal_code' => FluentRule::string()->required(),
]);Conditionals — when() and unless() from Laravel's Conditionable trait. Use these to branch field inclusion based on a flag without breaking the chain (shown in the building example above).
Inspecting and exporting a rule set
Reads of the in-memory RuleSet: predicates for branching code, debugging dumps, and the user-facing toArray() export that hands rules off to a Validator. The lower-level static compile* family lives under Compile pipeline — that's the transform surface for tooling and codegen.
toArray()/all()— compiled flat output, ready forValidator::make().all()is a Collection-style alias.[...$ruleSet]— spread viaIteratorAggregate; yields thetoArray()shape, so[...$parent, 'extra' => $rule]works.isEmpty()—truewhen no fields have been registered. Useful for "skip validation if empty" branches.hasObjectRules()—truewhen at least one field useseach()orchildren(). Useful for tooling that needs to distinguish flat from nested rule sets.flattenRules()— flattened dotted/wildcard form of the rules. Useful for codegen and debug logging:
RuleSet::from([
'address' => FluentRule::field()->required()->children([
'street' => FluentRule::string()->required(),
]),
'items' => FluentRule::array()->each([
'sku' => FluentRule::string()->required(),
]),
])->flattenRules();
// [
// 'address' => FluentRule::field()->required()->children([...]),
// 'address.street' => FluentRule::string()->required(),
// 'items' => FluentRule::array()->each([...]),
// 'items.*.sku' => FluentRule::string()->required(),
// ]dump()— returns['rules' => ..., 'messages' => ..., 'attributes' => ...]for inspection; does not terminate.dd()— dumps and terminates. Sugar for the same shape during development.
Validating data
validate() is the default entry point and throws ValidationException on failure. check() is the errors-as-data alternative. Both accept either an array or an Illuminate\Http\Request — passing the request directly keeps the $request->all() read scoped to the library, which keeps controllers clean for static-analysis rules that flag unsafe input access. The remaining methods (failOnUnknownFields, dropUnknownFields, stopOnFirstFailure, withBag) are per-call options chained before the terminal validate() / check() call.
Errors-as-data with check()
validate() throws ValidationException on failure. For import pipelines, batch jobs, and any flow where exceptions are the wrong control structure, use check() instead. It returns an immutable Validated object:
use SanderMuller\FluentValidation\RuleSet;
foreach ($rows as $row) {
$result = RuleSet::from($rules)->check($row);
if ($result->fails()) {
Log::warning('row rejected', $result->errors()->all());
continue;
}
$safe = $result->safe(); // Illuminate\Support\ValidatedInput, gives you ->only(), ->except(), ->collect()
$array = $result->validated(); // plain array (throws if the result failed)
insert_row($safe->all());
}| Method | Returns | Description |
|---|---|---|
->passes() | bool | Did validation pass? |
->fails() | bool | Inverse of passes() |
->errors() | MessageBag | All validation errors (empty bag on success) |
->firstError($field) | ?string | First error message for a field, or null |
->validated() | array | Validated data; throws ValidationException if it failed |
->safe() | ValidatedInput | Same data as validated(), wrapped for ->only()/->except()/->collect() access |
->validator() | ValidatorContract | Escape hatch for deep Laravel integration (->after(), ->sometimes(), extensions) |
check() runs the same internal engine as validate() (fast-check closures, wildcard expansion, batched DB queries). There is no double-parse; the result object just wraps the outcome.
Rejecting unknown fields
failOnUnknownFields() rejects input keys that don't match any rule in the set. If someone sends role when you only defined name and email, validation fails:
$validated = RuleSet::from([
'name' => FluentRule::string()->required(),
'email' => FluentRule::email()->required(),
])->failOnUnknownFields()->validate($request->all());
// Input: ['name' => 'John', 'email' => 'john@example.com', 'role' => 'admin']
// → ValidationException: "The role field is prohibited."Wildcard arrays are checked too. items.0.hack fails if only items.*.name is defined. You can customize the error message per field:
->validate($data, messages: ['role.prohibited' => 'This field is not allowed.']);TIP
For form requests, Laravel 13.4+ has a native #[FailOnUnknownFields] attribute that works automatically with HasFluentRules.
Silently dropping unknown fields
dropUnknownFields() is the lenient counterpart to failOnUnknownFields() — instead of rejecting unknown keys, it strips them from the validated() output. Top-level keys outside the rule set are already excluded; this flag extends the same behavior to nested array shapes declared via children(), each(), or dotted rule keys:
$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']]If both dropUnknownFields() and failOnUnknownFields() are set, failOnUnknownFields() wins — unknown keys trigger a validation error before the drop ever applies.
Stopping on first failure
stopOnFirstFailure() bails after the first field error. If the file upload fails, the 500 exists queries for items never run:
$validated = RuleSet::from([
'file' => FluentRule::file()->required()->max('10mb'),
'items' => FluentRule::array()->required()->each([
'sku' => FluentRule::string()->required()->exists('products', 'sku'),
]),
])->stopOnFirstFailure()->validate($request->all());The same applies inside wildcard arrays. If the first item fails, the rest are skipped.
Named error bags (withBag)
Multiple forms on one page (Fortify's update-password + reset-password, a Livewire multi-card screen, etc.) need separate error bags so their validation errors don't collide. Chain ->withBag($name) on the rule set; the thrown ValidationException's errorBag is set to that name:
RuleSet::from([
'current_password' => FluentRule::string()->required()->currentPassword(),
'password' => FluentRule::string()->required()->min(12),
])
->withBag('updatePassword')
->validate($input);Mirrors Laravel's Validator::validateWithBag() without forcing you back to the Validator::make(...) incantation. Only affects the thrown exception's bag; check() never throws and is unaffected.
Integrating with a raw Validator
For inspection (->failed(), ->errors(), ->valid()), check() already exposes the underlying Validator:
$validator = RuleSet::from($rules)
->check($request->all(), $customMessages)
->validator();The validator has already run. For pre-run hooks like ->after() or ->sometimes(), prefer the equivalent RuleSet mechanics (custom Rule classes, Rule::when(), modify()). If you genuinely need an unvalidated Validator, prepare() returns the compiled rules, messages, and attributes for hand-rolled Validator::make(...) use.
Using with custom Validators
If your application extends Illuminate\Validation\Validator directly (for example, in import jobs), you may extend FluentValidator instead:
use SanderMuller\FluentValidation\FluentValidator;
class JsonImportValidator extends FluentValidator
{
public function __construct(array $data, protected ?User $user = null)
{
parent::__construct($data, $this->buildRules());
}
private function buildRules(): array
{
return [
'*.type' => FluentRule::string()->required()->in(InteractionType::cases()),
'*.end_time' => FluentRule::numeric()
->requiredUnless('*.type', ...InteractionType::withoutDuration())
->greaterThanOrEqualTo('*.start_time'),
];
}
}FluentValidator resolves the translator and presence verifier from the container, calls prepare() on the rules, and sets implicit attributes. Cross-field wildcard references (requiredUnless('*.type', ...)) work automatically.
Migrating rules in a non-standard method? If your custom Validator holds its rules in a method that isn't named rules() (for example rulesWithoutPrefix() for a JSON-import pipeline), mark the method with #[SanderMuller\FluentValidation\FluentRules] so the migration Rector rules detect it. The attribute has no runtime effect; see the #[FluentRules] opt-in docs for the full semantics and guard interactions.
Compile pipeline (advanced)
Escape hatches for tooling, codegen, and framework interop. Most application code never reaches for these — validate(), check(), prepare(), and toArray() cover the common cases. They are exposed publicly so external Rector rules, PHPStan extensions, and the Livewire bridge can hook into the same compile pipeline RuleSet uses internally.
RuleSet::compile($rules)— compile a fluent-rules array to native Laravelstring|arrayrule format. The lowest-level transform.RuleSet::compileToArrays($rules)— compile to the array-of-rules shape Livewire's$this->validate()expects. Used byHasFluentValidationunder the hood.RuleSet::compileWithMetadata($rules)— compile alongside extracted custom messages and attribute labels in one pass. For tooling that needs the metadata without re-walking the rule tree.RuleSet::extractMetadata($rules)— extract[messages, attributes]from labelled fluent rules without compiling. For tooling that only wants the metadata side.$set->expandWildcards($data)— pre-expand wildcard rules against a concrete payload without validating. Useful when generating per-row error keys ahead of time.
Method reference
Alphabetical lookup of every public method. See the subsections above for usage; this table is for "does this method exist?" checks.
| Method | Returns | Description |
|---|---|---|
->all() | array | Collection-style alias of ->toArray(). |
->check($data, $messages = [], $attributes = []) | Validated | Validate without throwing. $data accepts array or Illuminate\Http\Request. See Errors-as-data with check(). |
RuleSet::compile($rules) | array | Compile fluent rules to native Laravel format. |
RuleSet::compileToArrays($rules) | array | Compile to array-of-rules shape for Livewire's $this->validate(). |
RuleSet::compileWithMetadata($rules) | array | Compile + return extracted messages and attributes in one pass. |
->dd() | never | Dump the rule set and terminate. |
RuleSet::define(fn ($rules)) | RuleSet | Create from a closure given a FluentSchema builder; drops the FluentRule:: prefix. |
->dump() | array | Return {rules, messages, attributes} for debugging. |
->except(...$fields) | RuleSet | Drop the named fields (variadic strings or single array). |
->expandWildcards($data) | array | Pre-expand wildcards against $data without validating. |
RuleSet::extractMetadata($rules) | array | Extract [messages, attributes] from labelled fluent rules. |
->dropUnknownFields() | RuleSet | Silently strip unvalidated array sub-keys from validated() output. Lenient counterpart to failOnUnknownFields(). |
->failOnUnknownFields() | RuleSet | Reject input keys not present in the rule set. |
->field($name, $rule) | RuleSet | Add a field via the fluent builder. |
->flattenRules() | array | Flat dotted/wildcard form of the rules; each/children are unwrapped. |
RuleSet::from([...]) | RuleSet | Create from a rules array. |
->get($field, $default = null) | mixed | Read a single field's rule (uncompiled), or $default if absent. |
->getIterator() / [...$ruleSet] | Traversable | Spread support; yields the toArray() shape. |
->hasObjectRules() | bool | true when at least one field uses each() or children(). |
->isEmpty() | bool | true when no fields are registered. |
RuleSet::make() | RuleSet | Empty rule set; chain ->field(...). |
->merge($ruleSet|$array) | RuleSet | Merge another RuleSet or array (later wins on collision). |
->modify($field, fn ($rule)) | RuleSet | Read-modify-write a single field; clones first; throws on missing key. |
->modifyChildren($field, $rules) | RuleSet | Sugar: later-wins merge into a FieldRule's children([...]) map. |
->modifyEach($field, $rules) | RuleSet | Sugar: later-wins merge into an ArrayRule's each([...]) map. |
->only(...$fields) | RuleSet | Keep only the named fields (variadic strings or single array). |
->prepare($data) | PreparedRules | Expand, extract metadata, compile. For hand-rolled Validator::make. |
->put($field, $rule) | RuleSet | Add or replace a single field's rule. |
->stopOnFirstFailure() | RuleSet | Stop validating after the first field fails. |
->toArray() | array | Compiled flat output; each() expanded to wildcards. |
->validate($data, $messages = [], $attributes = []) | array | Validate with full optimization (see Performance). $data accepts array or Illuminate\Http\Request. |
->when($cond, $cb) / ->unless(...) | RuleSet | Conditionally add fields (Laravel's Conditionable trait). |
->withBag($name) | RuleSet | Set the error bag name on the thrown ValidationException. |