Laravel Magazine
Laravel Validation: Custom Rule Objects and Invokable Rules

Laravel Validation: Custom Rule Objects and Invokable Rules

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.