Laravel Validation: Custom Rule Objects and Invokable Rules
Laravel's built-in validation rules cover the basics, but once your business logic gets specific — "this slug must be unique per tenant," "this coupon code must still be active" — string-based rules turn into unreadable noise. Rule objects fix that.
The problem with inline closures everywhere
You've probably written something like this:
$request->validate([
'coupon_code' => [
'required',
function ($attribute, $value, $fail) {
$coupon = Coupon::where('code', $value)->first();
if (! $coupon || $coupon->expires_at->isPast()) {
$fail('The coupon code is invalid or has expired.');
}
},
],
]);
It works, but it's not reusable, not testable in isolation, and it clutters the FormRequest. Move it into an invokable rule instead.
Building an invokable Rule object
php artisan make:rule ActiveCoupon --invokable
namespace App\Rules;
use App\Models\Coupon;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ActiveCoupon implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$coupon = Coupon::query()
->where('code', $value)
->first();
if (! $coupon) {
$fail('The :attribute does not exist.');
return;
}
if ($coupon->expires_at?->isPast()) {
$fail('The :attribute has expired.');
}
}
}
Now your FormRequest reads clearly:
public function rules(): array
{
return [
'coupon_code' => ['required', new ActiveCoupon],
];
}
Passing context into a rule
Rules aren't limited to a constructor-free signature. Pass whatever context the rule needs — a tenant ID, a model instance, a flag — straight through the constructor:
class UniqueSlugPerTenant implements ValidationRule
{
public function __construct(
private readonly int $tenantId,
private readonly ?int $ignoreId = null,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$query = Article::query()
->where('tenant_id', $this->tenantId)
->where('slug', $value);
if ($this->ignoreId) {
$query->whereKeyNot($this->ignoreId);
}
if ($query->exists()) {
$fail('That slug is already taken for this account.');
}
}
}
'slug' => [
'required',
new UniqueSlugPerTenant(
tenantId: $request->user()->tenant_id,
ignoreId: $this->article?->id,
),
],
Testing rules in isolation
Because a rule object is just a plain class, you can unit test it without spinning up a full request:
it('rejects an expired coupon', function () {
Coupon::factory()->create([
'code' => 'SUMMER26',
'expires_at' => now()->subDay(),
]);
$fail = fn ($message) => throw new RuntimeException($message);
expect(fn () => (new ActiveCoupon)->validate('coupon_code', 'SUMMER26', $fail))
->toThrow(RuntimeException::class, 'The coupon_code has expired.');
});
When to reach for a rule object vs. a closure
A quick, one-off check that's only used in a single form? A closure is fine. Anything that touches the database, needs constructor arguments, or gets reused across two or more forms belongs in a Rule object. At PHP Architect we push validation logic like discount codes, invite tokens, and per-workspace uniqueness checks into rule objects as soon as they show up in a second FormRequest — it keeps the request classes themselves down to a readable list of field names.