Laravel Magazine
Writing Custom Validation Rules with Laravel's Invokable Rule Objects

Writing Custom Validation Rules with Laravel's Invokable Rule Objects

Eric Van Johnson ·

If you've been writing Laravel validation rules since before the Illuminate\Contracts\Validation\ValidationRule contract landed, you probably remember the old two-method dance: implement passes() to return a boolean, then implement a separate message() method to explain what went wrong. It worked, but it split a single piece of logic across two methods and made it awkward to build a failure message with dynamic context.

The modern approach is a single invokable class. One method, validate(), does the check and calls a Closure to fail with whatever message you want, whenever you want. Let's build one.

Generating the rule

php artisan make:rule CouponCodeFormat

That drops a class in app/Rules implementing ValidationRule:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class CouponCodeFormat implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! preg_match('/^PHPARCH-[A-Z0-9]{6}-\d{2}$/', $value)) {
            $fail('The :attribute must match the PhpArchitect coupon format (e.g. PHPARCH-8K3F2Q-25).');
        }
    }
}

Notice there's no return. You call $fail() when something's wrong and simply do nothing when it's valid. The :attribute placeholder gets swapped for the field name automatically, same as Laravel's built-in rules.

Using it in a Form Request

namespace App\Http\Requests;

use App\Rules\CouponCodeFormat;
use Illuminate\Foundation\Http\FormRequest;

class ApplyCouponRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'coupon_code' => ['required', 'string', new CouponCodeFormat],
        ];
    }
}

Mix rule objects with string-based rules freely — Laravel handles both in the same array.

A domain-specific example: business email only

A common one for B2B products: reject free consumer email providers and require something that looks like a company domain.

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;

class BusinessEmailDomain implements ValidationRule
{
    private const BLOCKED_DOMAINS = [
        'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com',
    ];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $domain = strtolower(Str::after($value, '@'));

        if (in_array($domain, self::BLOCKED_DOMAINS, true)) {
            $fail('Please use your business email address, not a personal one.');
        }
    }
}

Register it with new BusinessEmailDomain next to Laravel's own email rule and you get both format validation and the domain check for free.

When a rule needs more than its own value

Sometimes a rule can't do its job by looking at $value alone. Say PhpArchitect runs a discount rule that's only valid if the order total clears a threshold — but the threshold check needs the other fields in the request. That's what DataAwareRule is for:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;

class DiscountRequiresMinimumOrder implements DataAwareRule, ValidationRule
{
    protected array $data = [];

    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $orderTotal = (float) ($this->data['order_total'] ?? 0);
        $minimumForDiscount = 50.00;

        if ($value === true && $orderTotal < $minimumForDiscount) {
            $fail("A discount can't be applied to orders under $".number_format($minimumForDiscount, 2).'.');
        }
    }
}

Laravel calls setData() for you before validate() runs, passing the entire array being validated, so you can reach into sibling fields like order_total even though the rule is attached to apply_discount.

There's a sibling interface, ValidatorAwareRule, for the rarer case where you need the actual Validator instance — for example if you want to manually add an error to a different field than the one the rule is attached to, or inspect rules already registered on the validator:

use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;

class SomeRule implements ValidatorAwareRule, ValidationRule
{
    protected Validator $validator;

    public function setValidator(Validator $validator): static
    {
        $this->validator = $validator;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // $this->validator->errors()->add('other_field', 'Something related failed too.');
    }
}

Why bother with classes over closures

Laravel also lets you pass a plain closure as a rule for one-off checks. Reach for an invokable class instead as soon as the logic is reusable, needs constructor arguments (an injected repository, a config value, a tenant ID), or is complex enough to deserve its own unit test. A CouponCodeFormatTest that asserts a handful of valid and invalid strings is a lot easier to write and maintain than a closure buried in a Form Request.

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.