juststeveking/fluent-validation
Define Laravel validation rules with a fluent, chainable PHP API instead of pipe-delimited strings. juststeveking/fluent-validation keeps rules readable, composable, and easy to maintain across form requests and controllers.
Install, then immediately prove it runs on your stack. The last release was 2022-11-10 — a Laravel 9 / PHP 8.1-era package with a single maintainer and zero dependents. Read the composer.json constraints, then verify on a throwaway branch against your actual Laravel/PHP matrix before it ever lands in a feature PR.
composer require juststeveking/fluent-validation
There is no config to publish and, in the normal case, no service provider to register — this is a set of rule builders that compiles to native Laravel rules, not a framework extension. If Composer resolves and the classes autoload, you are installed.
Where to look first, in order:
src/ — the builder classes and the base/factory entry point. Ten minutes of reading tells you exactly which methods exist and what each one expands to. There is no magic; the value is the chain, not hidden behavior.tests/ — the de-facto spec. The README is thin, so tests are the authoritative answer to "what does Rules::string()->required()->max(255) actually return?"composer.json — framework constraints. This is your compatibility gate.First use case: pick one login or registration FormRequest and port a single rules() method.
// before
public function rules(): array
{
return [
'email' => 'required|string|email|max:255|unique:users,email',
'password' => 'required|string|min:8|confirmed',
];
}
// after
public function rules(): array
{
return [
'email' => Rules::string()->required()->email()->max(255)->unique('users', 'email')->toArray(),
'password' => Rules::string()->required()->min(8)->confirmed()->toArray(),
];
}
The win is legibility and IDE autocompletion; the risk is behavior drift. Prove the compiled output is identical to the string version before merging — see Gotchas.
Compile inside rules(), never store builder objects. Treat the fluent chain as an authoring DSL that collapses to native rules at the boundary. Do the ->toArray() call in rules() so FormRequest always receives a plain array. This keeps the package behind one seam and makes a native rollback a one-file change.
public function rules(): array
{
return [
'name' => Rules::string()->required()->max(120)->toArray(),
'email' => Rules::string()->required()->email()->max(255)->unique('users')->toArray(),
'age' => Rules::integer()->nullable()->between(18, 120)->toArray(),
'accepted' => Rules::boolean()->required()->toArray(),
];
}
Extract shared rule sets into small named helpers. The highest-leverage use is repeated rule fragments — passwords, emails, slugs, addresses. Wrap them so call sites are semantic and the package stays replaceable.
final class UserRules
{
public static function email(): array
{
return Rules::string()->required()->email()->max(255)->unique('users', 'email')->toArray();
}
public static function password(): array
{
return Rules::string()->required()->min(8)->confirmed()->toArray();
}
}
// FormRequest
public function rules(): array
{
return [
'email' => UserRules::email(),
'password' => UserRules::password(),
];
}
That indirection is the whole game plan from a risk standpoint: if you later fork, replace, or drop the package, UserRules is the only class you rewrite.
Mix with native rules at the same level, but don't mix inside the same rule. Fluent builders produce arrays; native strings, Rule:: objects, and custom Rule classes all coexist fine as sibling values. What you should avoid is trying to make one field's constraint half-fluent, half-string — that is where shapes get confusing.
public function rules(): array
{
return [
'email' => UserRules::email(),
'role' => ['required', Rule::in(Role::cases())], // native
'avatar' => ['nullable', 'image', new MaxFileSize(2048)], // native + custom Rule
];
}
Keep conditionals and nesting native. sometimes, required_if, required_with, exclude_unless, nested array validation, and wildcards are where fluent helpers tend to thin out. If the builder supports them, verify the compiled output carefully; if it doesn't, leave that field as a native rule set. The two idioms can coexist in one rules() array — that is normal and fine.
Write regression tests that assert on the compiled array, not on behavior. This is the single most valuable habit with this package. Capture the native rules you expect, then assert the fluent builder produces the same shape. That converts "sensible defaults" from a promise into a CI contract, and it means a Laravel upgrade that changes toArray() output fails loudly in tests rather than softly in a 422 ticket.
it('compiles the login email rules to the expected native shape', function () {
expect(UserRules::email())->toBe([
'required', 'string', 'email', 'max:255', 'unique:users,email',
]);
});
If the package emits a different array shape (rule => constraint map vs. flat list), normalize at the boundary with a single array_map() helper and test that — then every call site uses the same normalized shape and your test asserts a stable contract.
Wrap it once, early. Even if you don't write a full façade, create one small internal entry point (e.g., a Validation class exposing email(), password(), slug()) that internally calls the package. Day-to-day usage feels identical, and a future swap is a single-class rewrite instead of a codebase hunt.
Custom rules: implement them as native Rule objects and drop them in alongside fluent output. Don't try to force them through the builder's chain unless the package explicitly supports it — a native Rule object compiles to the array cleanly and is auditable.
Confirm the output shape before writing anything real. The most common surprise with fluent-validation-style packages is the shape of the compiled array: flat string lists ('required', 'string', 'max:255') versus keyed maps ('rule' => 'max', 'value' => 255) versus a mixed form with objects. Whatever it is, pick it, normalize it at the FormRequest boundary, and never let the shape leak into serialization, OpenAPI generation, logging, or error-message customisation. One shape, one helper, one test.
Rule order still matters — fluently or not. bail, nullable, sometimes, and required semantics are order-sensitive in Laravel. A fluent chain implies order, so make sure the compiled array preserves it and matches what you intended with the string form. Diff the arrays; don't eyeball them.
Pin the version and commit the lockfile. Single maintainer, ~3 years without a release, zero dependents — you are the canary for Laravel major-upgrade breakage. composer require juststeveking/fluent-validation:^0.0.2 (or your resolved version), commit composer.lock, and add one dedicated CI job that runs the package's own tests plus your regression tests against each Laravel version you support. This is a five-minute setup that pays for itself the first time a Laravel 11/12 upgrade lands.
"Sensible defaults" can silently change behavior. Read the tests for each helper you use — specifically the exact constraints a helper like string() or email() expands into (max? between? unique scoping?). If a helper imposes a constraint you didn't ask for, either don't use that helper or override it. Silent defaults are the primary defect source with this package, and only the test suite catches them.
Localization and error messages are untouched. The package re-shapes authoring, not output. If your app customizes lang/*/validation.php or passes custom messages to validate(), nothing changes — but verify, because if the builder emits rules in a non-standard shape, message key lookup can break. Confirm with a $request->validate() test that asserts a specific error message string, not just that an error occurred.
Advanced surfaces stay native. sometimes, required_if, Password::defaults(), nested wildcards, after hooks, exclude_if, and conditional rules are where this package is weakest. If you need those, use native rules — even in the same rules() method. The package's job is the common 80%; using it everywhere is how you get a second, half-supported validation idiom.
Debugging: dump the compiled array first. When a rule "doesn't work," the cause is almost always a shape mismatch, not a behavior bug. Add a temporary dd($this->rules()) in the FormRequest or dd(UserRules::email()) at the call site and compare against the string form you'd have written. If the arrays match, the problem is upstream (request payload, middleware, authorize()); if they don't, you've found the defect.
Extension points worth knowing:
Rule objects as siblings; they compile cleanly.Rules/Rule class), consider a thin app-level façade that returns your house style, so call sites are short and the package is one use statement deep.src/ into app/Support/Validation/ and dropping the dependency is an afternoon's work. Do that if the package ever blocks a Laravel upgrade — it's cheaper than maintaining a fork in a public repo, and it makes the code yours to edit.Onboarding note: juniors and auditors read strings fine. A fluent chain is clearer for common rules and opaquer for people who don't know the package. Put the compiled-output regression test next to the fluent rule, or a short "how we write rules here" note in the repo — otherwise the DSL reads as magic to the next person.
How can I help you explore Laravel packages today?