sourcetoad/rule-helper-for-laravel
A Laravel package that adds fluent helpers for building validation rule arrays, including RuleSet and Rule classes, reusable defined rule sets, and conditional required helpers such as requiredIfAll and requiredIfAny.
Install and pin to the right major. There is a hard 1:1 mapping between the Laravel version and the package major, so install against your framework version rather than blindly:
composer require sourcetoad/rule-helper-for-laravel
| Laravel | Package |
|---|---|
| 13.x | 7.x (or 6.3.x) |
| 12.x | 6.x |
| 11.x | 5.x |
| 10.x | 4.x |
Note v7 dropped Laravel 12 and PHP 8.2, so a v7 install only makes sense on a Laravel 13 app on PHP 8.3+.
No publish step, no config file. There is nothing to vendor:publish and no package service provider you need to interact with for basic usage — you import two classes and start writing rules. The only "registration" you do is your own AppServiceProvider::boot() if you want shared rule sets.
Where to look first: the two entry points, both in Sourcetoad\RuleHelper:
Rule — a static mirror of Illuminate\Validation\Rule plus every built-in rule that Laravel only exposes as a string. Use this when you want to stay in array-of-rules land but gain typed, autocompletable calls.RuleSet — a fluent, chainable builder that produces a rule set for one field. Use this when you want a readable pipeline instead of a comma-joined string.First use case — clean up one messy FormRequest. Take the worst field in your app and rewrite it:
use Sourcetoad\RuleHelper\RuleSet;
use Illuminate\Validation\Rules\Unique;
public function rules(): array
{
return [
'title' => RuleSet::create()
->required()
->unique('posts', 'title', fn (Unique $rule) => $rule->withoutTrashed())
->max(255),
];
}
That single change is the whole value proposition: the modifier closure (withoutTrashed) is where string rules usually fall apart into Rule::unique(...)->ignore(...) mixed with raw strings. Once one field looks like this, do the rest of the request.
Fluent RuleSet for anything with a modifier closure. The chain really pays off when a rule needs configuration — unique, exists, password, image, enum, dimensions. The callback receives the underlying Laravel rule object, so you keep full access to the framework API without leaving the chain:
'email' => RuleSet::create()
->required()
->email(fn ($rule) => $rule->strict())
->unique('users', 'email')
->max(255),
Static Rule for hybrid arrays. Don't force a full rewrite. Rule::required(), Rule::max(255), Rule::base64() etc. slot in beside legacy strings, which makes migration incremental:
'title' => [
Rule::required(),
'string', // leave stragglers alone for now
Rule::unique('posts'),
Rule::max(255),
],
Defined rule sets are the killer feature for multi-tenant/multi-form apps. Register once in AppServiceProvider::boot() and reuse the semantics everywhere:
use Sourcetoad\RuleHelper\RuleSet;
public function boot(): void
{
RuleSet::define('existing_email', RuleSet::create()->email()->exists('users', 'email'));
RuleSet::define('existing_user', RuleSet::create()->required()->exists('users', 'id'));
}
'to' => RuleSet::useDefined('existing_email')->required(),
'bcc' => RuleSet::useDefined('existing_email'),
'cc' => RuleSet::create()->required()->concatDefined('existing_email'),
useDefined() gives you the base set so you can extend it per-field; concatDefined() splices a set into a larger chain. Note that defined set names can also be backed by an enum (added in 5.3.0), which is much nicer than magic strings:
RuleSet::useDefined(DefinedRuleSet::ExistingEmail)
requiredIfAll / requiredIfAny for multi-condition requirements. Laravel's required_if is a flat list, and "required only if all of these are true" has no native equivalent. This is where the package earns its place even in a codebase that otherwise uses string rules:
RuleSet::create()->requiredIfAny([
Rule::requiredIf(fn () => $this->user()->isAdmin()),
Rule::requiredIf(fn () => $this->input('type') === 'custom'),
]),
Pass actual Rule objects built from the package's requiredIf helper — these combinators are evaluating the underlying RequiredIf rules, so arbitrary strings won't work.
Wrap it at the app level. Every project ends up with cross-cutting concerns (tenant scoping on exists, soft-delete awareness, a house currency format). Put those in your own small builder class or a App\Support\Rules facade that calls RuleSet internally, so if the package goes away you change one file instead of 200 form requests.
Test through the validator, not the rule array. Because many helpers now return rule objects (see Gotchas), asserting on $request->rules() is brittle. Assert behaviour:
$response = $this->postJson('/api/posts', ['title' => '']);
$response->assertJsonValidationErrors('title');
Where you must assert the shape, normalise first — iterate the RuleSet (it implements IteratorAggregate) or cast it to an array before comparing.
Fluent helpers return objects, not strings — and that breaks naive code. Since v6.0, date, email, file, and image return rule objects; v7.0 goes further and makes string and integer fluent too. Anything that treats a rule array as strings will silently misbehave or throw: implode(',', $rules), str_contains($rule, 'email'), building a custom validation message map from rule strings, and — most commonly — snapshot/unit tests asserting 'email' => 'required|email|max:255'. Audit those before upgrading a major.
Two Rule classes, same short name. Sourcetoad\RuleHelper\Rule is not a drop-in replacement for Illuminate\Validation\Rule in every scenario. Feature-parity gaps exist, and forEach was deliberately removed in v3.0.0 because Laravel's array flattening can't carry NestedRules inside a rule list. If you need wildcard iteration (items.*.sku), keep the framework's Rule::forEach. Import with an alias when both are needed:
use Illuminate\Validation\Rule as LaravelRule;
use Sourcetoad\RuleHelper\Rule;
Type-hint the modifier closure correctly or you get a TypeError. The callback receives a concrete rule class (Unique, Exists, Dimensions, …). There was an explicit closure-type fix in v6.3.0, so on older minors you may hit mismatches. Always type the param and let the IDE tell you:
->unique('posts', 'title', fn (Unique $rule) => $rule->ignore($post->id))
Defined sets live in boot(), which means ordering and test-context matter. If you define sets in a provider that runs after something consumes them, or you build rules outside the app container (bare unit tests, plain new form requests instantiated before boot), useDefined() throws. Prefer defining them in a dedicated provider listed early in bootstrap/providers.php.
requiredIfAll / requiredIfAny need real RequiredIf rules. They are combinators over Laravel's RequiredIf objects, not over arbitrary closures or required_if:field,value strings. Build each member with Rule::requiredIf(...) (or the package's own helper) and verify the "all vs any" semantics with actual request tests — this is the one area where reading the source is faster than guessing.
concatDefined order is not normalised. Concatenating a set that already contains required into a chain that also calls required yields duplicates. Laravel tolerates most duplicates, but sometimes/nullable interactions can flip unexpectedly. Keep a convention: base sets should never contain modifiers you intend to add per-field.
Debug by dumping the resolved rules, not the builder. dd($request->rules()) can show objects that look opaque. Use the validator to see what Laravel actually parsed:
$validator = Validator::make($input, $request->rules());
dump($validator->getRules()); // flattened, stringified
dump($validator->errors()->toArray());
Run this in php artisan tinker while building a tricky field; it's the fastest path when a chain "looks right" but the request still passes/fails.
Don't expect a macro/extension API. RuleSet is a concrete builder class, not Macroable. For reusable behaviour, compose in your own class (see Implementation Patterns) rather than trying to register macros or subclass deeply — internal method changes across minors will bite you.
Budget for bus-factor risk. Nine stars, zero dependents, and a single-vendor release cadence (frequent, well-changelogged — but small). That doesn't disqualify it — the API is thin, MIT, and easy to fork — but: pin the exact version in composer.json, keep the dependency behind your own Rules helper, and check the release notes before each Laravel upgrade, since breaking changes have historically coincided with framework majors. The upside of a thin wrapper is that ripping it out is a mechanical RuleSet::create()->required() → 'required' conversion, not a rewrite.
How can I help you explore Laravel packages today?