Writing Custom Validation Rules with Laravel's Invokable Rule Objects
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.