saritasa/laravel-fluent-validation
A Laravel package that adds fluent, chainable validation rule builders, letting you define complex validation logic in expressive PHP instead of long arrays or strings. It simplifies custom rules and keeps validators readable, reusable, and easy to maintain.
Install and locate the entry point
composer require saritasa/laravel-fluent-validation
This is a rules-only package — there is no config to publish and (typically) no service provider to register. Confirm both by checking composer.json for an extra.laravel.providers block and by listing vendor/saritasa/laravel-fluent-validation/src/. The README is thin at 8 stars, so the source is the documentation:
src/ root — the entry point / factory (static make()-style call or a FluentRule-like class) and any base builder.src/Rules/ (or similar) — one builder per rule family (string, numeric, array, date, file, boolean). Each implements __toString()/Stringable, which is what makes it drop-in compatible with Laravel.Before writing code, run one throwaway assertion to learn the contract:
dump((string) \Saritasa\LaravelFluentValidation\FluentRule::string()->required()->max(255));
// expect: "required|string|max:255" (or an array — check which)
First use case: shrink a FormRequest
This is the whole value proposition — replacing comma-string soups with chains in existing rules() methods, without touching controllers.
// before
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'age' => ['nullable', 'integer', 'min:18', 'max:120'],
];
}
// after
public function rules(): array
{
return [
'email' => FluentRule::string()->required()->email()->max(255)
->unique('users', 'email'),
'age' => FluentRule::integer()->nullable()->min(18)->max(120),
];
}
Because Laravel's ValidationRuleParser accepts Stringable objects, the builders work anywhere rules are accepted: FormRequest::rules(), $request->validate(), Validator::make(), and Rule::array()-style nested definitions.
Keep builders out of controllers. Return them from FormRequest::rules() or from a dedicated Rules/RuleSet class. Controllers should never see validation syntax.
Shared rule sets across entry points. If the same payload is validated by an API controller, an import job, and a console command, define the rule set once as a static method and let the builders be the single source of truth:
final class UserRules
{
public static function store(): array
{
return [
'name' => FluentRule::string()->required()->max(255),
'email' => FluentRule::string()->required()->email()->unique('users'),
'password' => FluentRule::string()->required()->min(8)->confirmed(),
];
}
public static function update(int $userId): array
{
return [
'email' => FluentRule::string()->sometimes()->email()
->unique('users', 'email')->ignore($userId),
];
}
}
Conditional rules. Prefer the builder's when()/sometimes() if present; otherwise fall back to Laravel's native Rule::when() or plain if branches inside rules(). Don't try to express required_if-style logic in a chain if the builder's parameter order differs from Laravel's — check the source, because a silently wrong parameter order produces a rule that never fires rather than a crash.
Mix freely with native objects. The builder doesn't need to cover everything. Combine with Rule::unique()->where(...), Rule::in(), Rule::enum(), and Password::defaults() in the same array — Laravel flattens and normalizes on its own:
'status' => [FluentRule::string()->required(), Rule::enum(Status::class)],
Wildcards and nested arrays. Build per-field rules and let the key carry the wildcard; loop when the shape varies:
$rules = ['items' => FluentRule::array()->required()->min(1)];
foreach (['sku', 'qty'] as $field) {
$rules["items.*.{$field}"] = FluentRule::string()->required();
}
One definition, two consumers. The real day-to-day win over string rules: since a builder is an object, you can walk the same rule set to emit OpenAPI/JSON-Schema. Write a small toSchema() mapper over your UserRules::store() output and validation and docs stop drifting.
Testing. Rule definitions are trivially unit-testable — assert on the cast string, or snapshot the whole array:
$this->assertSame('required|string|max:255', (string) FluentRule::string()->required()->max(255));
Then keep 2–3 feature tests ($this->postJson(...)->assertJsonValidationErrors(...)) per rule set, because a chain that stringifies correctly can still be semantically wrong (see ordering, below).
It is archived — verify Laravel compatibility before adopting. With 8 stars, 0 dependents, and a last release of 2024-01-08, there will be no upstream fixes for Laravel 11/12. Read the illuminate/support / illuminate/validation constraint in composer.json first. If you adopt, pin the version and plan to either fork it or wrap it (see extension points) so replacement is a one-file change.
Rule order is not cosmetic. bail must be first to short-circuit; nullable before type rules; required interacts with both. Confirm whether the builder emits rules in chain order or sorts them — if it sorts, bail and nullable positioning may change behavior. Write one test per chain that depends on short-circuiting.
Colons and commas will bite you. Any rule with parameters (max:255, regex:/^a,b$/, in:foo,bar) has to survive stringification. Values containing commas — especially in: and regex: — break the string form. Where the values are dynamic, use the array form (Rule::in($values)) instead of a builder method that concatenates.
Cast before you compare or serialize. Builders are objects. == comparisons, json_encode, array cache storage, and array_unique on a rules array will not do what you expect. array_map('strval', $rules) (or (string)) before diffing, caching, or logging. Casting also makes debugging sane:
dd(array_map('strval', (new StoreUserRequest())->rules()));
Don't expect messages or attributes. messages(), attributes(), and prepareForValidation() still live on the FormRequest and are keyed by rule name / field.rule. Fluent builders almost certainly don't carry per-rule messages, so a custom message for email.unique still comes from messages() with the literal rule name.
unique/exists closures and ignores. Verify the builder's unique() accepts ignore(), where(), and model instances the way Rule::unique() does. If it only takes table,column, use the native object for those fields and don't fight it.
Extension points. If the builder base is Macroable, add macros rather than forking. Regardless, wrap every call site in your own thin helper (App\Support\Rules::string()) so that swapping this package for native Illuminate\Validation\Rules or another fluent library later is a single-file edit rather than a grep across 40 FormRequests. Also add @method docblocks to that wrapper — these builders rarely ship generics, and without them you lose IDE completion on the chains, which is half the reason to use them.
Sanity-check the semantics, not just the string. The failure mode of fluent rule builders is a chain that casts to valid-looking Laravel syntax with the wrong parameter in the wrong slot. Every rule set you port should keep at least one passing/failing feature test per branch.
How can I help you explore Laravel packages today?