Custom Validation Rules in Laravel 13
Built-in rules cover most situations, but sooner or later you hit a check that required|string|max:255 cannot express. A Brazilian CPF number, a credit card with a Luhn checksum, a slug that must not collide with reserved routes – these need custom logic. Laravel gives you three ways to build it: Rule classes for reusable checks, closures for one-off cases, and after() hooks for cross-field logic that runs after all individual rules have been evaluated.
Rule Classes
Generate a custom validation rule with Artisan:
php artisan make:rule Uppercase
This creates app/Rules/Uppercase.php implementing the ValidationRule interface. The interface requires a single method – validate – that receives the attribute name, its value, and a $fail closure you call when the check does not pass:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class Uppercase implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (strtoupper($value) !== $value) {
$fail('validation.uppercase')->translate();
}
}
}
Attach it the same way as any other rule:
$request->validate([
'company_name' => ['required', 'string', new Uppercase],
]);
The $fail closure accepts a raw string ($fail('Must be uppercase.')) or a translation key with ->translate(). The second form keeps messages in lang/en/validation.php where translators can maintain them without touching PHP code.
Translation placeholders work too. Pass an array as the first argument to translate():
$fail('validation.max_amount')->translate([
'currency' => $this->currency,
'limit' => number_format($this->limit),
]);
The :currency and :limit placeholders in the translation string get replaced automatically.
Passing Configuration Through the Constructor
Most real-world rules need parameters. Suppose you validate that a monetary amount does not exceed a per-currency limit:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class MaxAmount implements ValidationRule
{
public function __construct(
private string $currency,
private int $limit,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value > $this->limit) {
$fail('validation.max_amount')->translate([
'currency' => $this->currency,
'limit' => number_format($this->limit),
]);
}
}
}
Usage:
$request->validate([
'amount' => ['required', 'numeric', new MaxAmount('USD', 10_000)],
]);
Constructor injection gives you full control over what the validation rule checks. Custom parameters come in through typed constructor arguments instead of colon-separated strings, so you can pass Eloquent models, config values, or anything else the rule needs to make its decision.
Accessing Other Fields with DataAwareRule
When validation depends on sibling fields – checking that a discount percentage does not exceed what the product category allows, for instance – implement DataAwareRule. Laravel calls setData() before validation runs, giving the rule access to everything in the request:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
class DiscountLimit 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
{
$category = $this->data['category'] ?? null;
$maxDiscount = match ($category) {
'electronics' => 15,
'clothing' => 40,
'food' => 5,
default => 10,
};
if ($value > $maxDiscount) {
$fail("Discount for {$category} cannot exceed {$maxDiscount}%.");
}
}
}
The $data array mirrors what $request->all() returns. If you need the validator instance instead – to add errors to other fields or read the current error state – implement ValidatorAwareRule and receive a Validator via setValidator():
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;
class UniqueAcrossTypes implements ValidationRule, ValidatorAwareRule
{
protected Validator $validator;
public function setValidator(Validator $validator): static
{
$this->validator = $validator;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Access other validated data or existing errors
$existingErrors = $this->validator->errors();
if ($existingErrors->has('sku')) {
return; // No point checking if SKU already failed
}
// ... cross-field check
}
}
Both interfaces can be combined on the same class when needed.
Closure Rules – Quick One-Off Validation
Not every custom validation function deserves its own class. For a check you need in a single place, pass a closure directly in the rules array:
$request->validate([
'slug' => [
'required',
'string',
'max:80',
function (string $attribute, mixed $value, Closure $fail) {
$reserved = ['admin', 'api', 'login', 'register', 'dashboard'];
if (in_array($value, $reserved, true)) {
$fail('This slug is reserved and cannot be used.');
}
},
],
]);
The signature is identical to a Rule class’s validate method. The trade-off is simple: closures are faster to write, but they cannot be reused and they clutter controllers once the logic grows past a few lines. If you catch yourself copying a closure between two Form Requests, extract it into a Rule class.
Closures work well for environment-specific checks too:
$request->validate([
'coupon' => [
'required',
'string',
function (string $attribute, mixed $value, Closure $fail) {
$coupon = Coupon::where('code', $value)
->where('expires_at', '>', now())
->first();
if (null === $coupon) {
$fail('This coupon is invalid or expired.');
}
},
],
]);
Implicit Rules – Validating Empty Fields
Standard rules skip execution when the field is not present or contains an empty string. That makes sense for max or email, but some custom checks need to run on absent fields – a rule that enforces “at least one of these fields must be present,” for instance.
Add the --implicit flag when generating:
php artisan make:rule AtLeastOnePhone --implicit
The generated class works like any ValidationRule, but it runs even when the attribute is null, an empty string, or entirely missing from the request. Here is an example that combines --implicit with DataAwareRule:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
class AtLeastOnePhone implements DataAwareRule, ValidationRule
{
/**
* Implicit rules fire on missing/empty fields.
* This property is added by the --implicit stub.
*/
public bool $implicit = true;
protected array $data = [];
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$mobile = trim($this->data['mobile_phone'] ?? '');
$home = trim($this->data['home_phone'] ?? '');
if ('' === $mobile && '' === $home) {
$fail('Provide at least one phone number.');
}
}
}
The $implicit = true property tells the validator not to skip this rule on empty values. Without it, the rule would never fire when both fields are empty because Laravel skips standard rules before the validate method executes.
Credit Card Validation with the Luhn Algorithm
Credit card number validation is one of the most common custom rule recipes. A Luhn check verifies the card number’s checksum without calling any external API:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class CreditCard implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$digits = preg_replace('/\D/', '', $value);
if (strlen($digits) < 13 || strlen($digits) > 19) {
$fail('validation.credit_card_length')->translate();
return;
}
if (false === $this->passesLuhn($digits)) {
$fail('validation.credit_card')->translate();
}
}
private function passesLuhn(string $digits): bool
{
$sum = 0;
$alt = false;
for ($i = strlen($digits) - 1; $i >= 0; $i--) {
$n = (int) $digits[$i];
if ($alt) {
$n *= 2;
if ($n > 9) {
$n -= 9;
}
}
$sum += $n;
$alt = !$alt;
}
return 0 === $sum % 10;
}
}
The algorithm doubles every second digit from the right, subtracts 9 when the result exceeds 9, and checks whether the total is divisible by 10. Every major card network (Visa, Mastercard, Amex, Discover) uses Luhn as a first-line check.
Use the rule alongside digits_between and your payment gateway’s own server-side verification:
$request->validate([
'card_number' => ['required', 'string', new CreditCard],
'card_expiry' => ['required', 'date_format:m/y', 'after:today'],
'card_cvv' => ['required', 'digits_between:3,4'],
]);
Luhn catches typos before the request ever leaves the server. It does not replace gateway validation – Stripe and Braintree reject invalid numbers on their end too – but it saves a network round-trip for obvious mistakes and gives the user instant feedback.
After Hooks – Post-Validation Logic
Some checks only make sense once all field-level rules have been evaluated. Comparing two dates, checking a database constraint that depends on multiple fields, verifying a third-party API – these belong in an after() hook rather than a Rule class. Note that after() callbacks execute after validation completes, even if some field-level rules failed.
In a Form Request:
use Illuminate\Validation\Validator;
public function after(): array
{
return [
function (Validator $validator) {
$start = $this->input('start_date');
$end = $this->input('end_date');
if ($start && $end && $start > $end) {
$validator->errors()->add(
'end_date',
'End date must be on or after start date.'
);
}
},
];
}
Use $this->input() instead of $this->validated() here, because validated() throws an exception when there are validation errors – and after() callbacks run even when some rules failed.
With Validator::make(), chain ->after() on the validator instance:
$validator = Validator::make($request->all(), [
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1',
]);
$validator->after(function (Validator $v) use ($request) {
$productId = $request->input('product_id');
$quantity = $request->input('quantity');
if ($productId && $quantity) {
$available = Product::find($productId)?->stock ?? 0;
if ($quantity > $available) {
$v->errors()->add('quantity', 'Not enough stock for this product.');
}
}
});
$validated = $validator->validate();
For complex applications with many post-validation steps, extract each check into an invokable class:
public function after(): array
{
return [
new ValidateShippingZone,
new ValidateInventory,
new ValidateFraudScore,
];
}
Each invokable receives a Validator instance through its __invoke method. This keeps the Form Request clean and the individual checks testable in isolation.
Organizing Rules in Larger Projects
A handful of rules fit in app/Rules/ without any structure. Once you have 15 or more, group them by domain:
app/Rules/
├── Payment/
│ ├── CreditCard.php
│ ├── ExpiryDate.php
│ └── CvvLength.php
├── Shipping/
│ ├── PostalCode.php
│ └── ShippingZone.php
└── User/
├── UniqueEmail.php
└── StrongPassword.php
Laravel autoloads everything under app/ through Composer’s PSR-4 mapping, so subdirectories work without any extra configuration. Adjust the namespace to match:
namespace App\Rules\Payment;
When a rule grows complex enough to need its own tests, helpers, or config, consider packaging it as a standalone composer package. The ValidationRule interface has no dependencies beyond the Closure import, so rules are easy to extract.
Phone Number Validation – A Practical Recipe
Another frequently requested custom rule: validating phone numbers with country-specific patterns. A full E.164 parser is overkill for most apps, but a regex-per-country rule handles the common case well:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
class PhoneNumber implements DataAwareRule, ValidationRule
{
protected array $data = [];
private array $patterns = [
'US' => '/^(\+1)?[2-9]\d{9}$/',
'GB' => '/^(\+44)?[1-9]\d{9,10}$/',
'DE' => '/^(\+49)?[1-9]\d{6,13}$/',
];
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$country = $this->data['country'] ?? 'US';
$digits = preg_replace('/[\s\-\(\)]/', '', $value);
$pattern = $this->patterns[$country] ?? $this->patterns['US'];
if (1 !== preg_match($pattern, $digits)) {
$fail('validation.phone_invalid')->translate([
'country' => $country,
]);
}
}
}
This rule reads the country field from sibling data and applies the matching regex. Usage:
$request->validate([
'country' => ['required', Rule::in(['US', 'GB', 'DE'])],
'phone' => ['required', 'string', new PhoneNumber],
]);
For production apps that handle dozens of countries, consider a library like libphonenumber instead of maintaining your own regex map. The custom rule pattern stays the same – only the internals of validate() change. Wrapping a third-party library in a Rule class keeps the dependency contained in one file and gives you a single place to add caching or logging if the library becomes a bottleneck.
Custom Error Messages in Language Files
Rule classes that call $fail('validation.some_key')->translate() expect a matching entry in lang/en/validation.php. Place all custom rule messages under a dedicated section:
// lang/en/validation.php
return [
// ... built-in messages ...
'phone_invalid' => 'The :attribute is not a valid phone number for :country.',
'max_amount' => 'The :attribute must not exceed :limit :currency.',
'credit_card' => 'The :attribute is not a valid card number.',
'credit_card_length' => 'The :attribute must be between 13 and 19 digits.',
'uppercase' => 'The :attribute must be entirely uppercase.',
];
The :attribute placeholder is replaced with the field name automatically. Additional placeholders (:country, :limit) come from the array passed to translate().
For per-attribute overrides, use the custom array in the same file:
'custom' => [
'card_number' => [
'credit_card' => 'Please double-check your card number.',
],
],
This gives card_number a different message from other fields using the same CreditCard rule. Multilingual apps replicate the same structure in lang/fr/validation.php, lang/es/validation.php, etc.
Custom Rules in the Validation Chain
A custom rule participates in the validation chain just like a built-in one. Placement matters.
Order affects execution. Rules run left to right. Put type-checking rules (string, numeric, integer) before your custom rule so that your validate() method can assume the value has the right type:
$request->validate([
'amount' => ['required', 'numeric', 'min:0', new MaxAmount('EUR', 5_000)],
]);
If numeric fails, Laravel stops at that point (assuming bail is in effect) and never calls MaxAmount::validate(). Without numeric first, the custom rule receives a raw string and has to handle type coercion itself.
bail stops on first failure. Place bail at the start of the chain when you want to prevent custom rules from running after a basic check fails. This is especially useful for expensive rules that hit the database or an external API:
$request->validate([
'coupon' => ['bail', 'required', 'string', 'max:20', new ValidCoupon],
]);
Without bail, all rules run regardless of earlier failures. That means ValidCoupon would still query the database even when max:20 already rejected the input.
Mixing built-in and custom rules. Array syntax is required whenever a closure or Rule object appears, but you can still combine them with string-based rules in the same array:
$request->validate([
'iban' => ['required', 'string', 'alpha_num', 'size:22', new ValidIban],
]);
Built-in rules (alpha_num, size:22) handle the format check, and the custom rule handles the IBAN checksum. Each does what it is best at.
Testing Custom Rules
Rule classes are plain PHP objects. Test them through the Validator facade without booting the full HTTP stack:
use App\Rules\CreditCard;
use Illuminate\Support\Facades\Validator;
test('rejects invalid card number', function () {
$validator = Validator::make(
['card' => '1234567890123'],
['card' => [new CreditCard]]
);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('card'))
->toBe('The card is not a valid card number.');
});
test('accepts valid Visa number', function () {
$validator = Validator::make(
['card' => '4111111111111111'],
['card' => [new CreditCard]]
);
expect($validator->passes())->toBeTrue();
});
For rules that implement DataAwareRule, feed sibling data through the validator – setData() gets called automatically:
test('discount exceeds category limit', function () {
$validator = Validator::make(
['category' => 'food', 'discount' => 10],
['discount' => [new DiscountLimit]]
);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('discount'))
->toContain('cannot exceed 5%');
});
test('discount within limit passes', function () {
$validator = Validator::make(
['category' => 'clothing', 'discount' => 30],
['discount' => [new DiscountLimit]]
);
expect($validator->passes())->toBeTrue();
});
For implicit rules, test both the “present but invalid” and “missing entirely” cases to verify the implicit behavior works:
test('implicit rule fires when field is missing', function () {
$validator = Validator::make(
['home_phone' => ''],
[
'mobile_phone' => [new AtLeastOnePhone],
'home_phone' => [new AtLeastOnePhone],
]
);
expect($validator->fails())->toBeTrue();
});
Testing after() hooks requires a full request cycle through the controller, since the hook lives on the Form Request. Use Laravel’s HTTP test methods:
test('rejects order when product is out of stock', function () {
$product = Product::factory()->create(['stock' => 0]);
$this->postJson('/api/orders', [
'product_id' => $product->id,
'quantity' => 5,
])
->assertUnprocessable()
->assertJsonValidationErrors(['quantity']);
});
For edge cases around error messages, assert the exact message to catch translation regressions:
test('max amount rule shows formatted limit', function () {
$validator = Validator::make(
['amount' => 15_000],
['amount' => [new MaxAmount('USD', 10_000)]]
);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('amount'))
->toContain('10,000')
->toContain('USD');
});
When Validation Is Not Working
Custom validation that silently passes when it should fail is one of the most common support questions. Here is a checklist, ordered by how often each cause appears.
Rule never fires on empty input. Standard rules skip fields that are absent or contain an empty string. If your custom rule must run on empty values, generate it with --implicit (which sets $implicit = true on the class) or add required before it in the chain. This trips up boolean validation especially – 'active' => [new MyBooleanRule] does nothing when the checkbox is unchecked and the field is missing from the POST body entirely.
Closure used with pipe syntax. Custom validation functions via closures only work in the array syntax. Concatenating a closure to a pipe string causes a PHP TypeError at runtime:
// Wrong – TypeError: cannot concatenate Closure to string
'title' => 'required|string|' . function (...) { ... }
// Correct – use array syntax for closures
'title' => ['required', 'string', function (...) { ... }]
Request validation not triggering at all. If your Form Request rules seem to have no effect, check three things: the authorize() method returns true (returning false yields a 403, not validation errors), the Form Request is type-hinted in the controller method signature (not just Request), and you are sending the request to the correct route. A POST to a GET route returns 405, not validation errors. Also verify that the class extends Illuminate\Foundation\Http\FormRequest, not the base Request.
Boolean validation not working. The built-in boolean rule accepts true, false, 1, 0, "1", and "0". It rejects "yes", "on", and any other truthy string. When a checkbox sends "on", the field fails boolean even though it looks valid. Use accepted for checkboxes instead. The boolean:strict variant is even narrower – it only accepts actual PHP true and false, rejecting all string and integer representations. See the rules reference for the full comparison.
$fail called but no error shows up. If your Blade template checks $errors->get('field') but you call $fail with a different attribute name inside an after() hook, the message lands under the wrong key. Double-check the field name passed to $validator->errors()->add(). Also make sure the template reads from the correct error bag if you use named bags.
Validation passes in tests but fails in browser. Form submissions send strings, not typed values. A rule that checks 0 === $value passes in a test where you pass an integer but fails in production where the value arrives as "0". Cast or normalize the value inside the rule before comparing.
Rule works alone but not in a Form Request. If the same Rule class passes when tested directly with Validator::make() but fails inside a Form Request, check the prepareForValidation() method. Input transformation there can change the value your rule receives. Also check for middleware that modifies input (TrimStrings, ConvertEmptyStringsToNull) – a field you expect to be an empty string might arrive as null.
Custom rule triggers on every request. If you add a validation rule to a base Form Request class that other requests extend, it fires on every request that inherits from it. Rule classes added via new RuleName in a specific rules array only run for that field on that request. Keep custom rules scoped to the Form Request that actually needs them, not in a shared parent.
Choosing the Right Approach
| Situation | Use |
|---|---|
| Reusable check, needs parameters | Rule class with constructor |
| One-off check in a single controller | Closure |
| Check depends on other validated fields | DataAwareRule or after() hook |
| Check runs on empty/missing fields | Implicit rule (--implicit) |
| Check requires external API or DB query | after() hook (guard with an if on $validator->errors()) |
| Check needs to add errors to multiple fields | ValidatorAwareRule or after() hook |
Start with closures, graduate to Rule classes when you reuse them, and reach for after() hooks when the logic spans multiple fields or should run only after field-level checks complete.
The validation basics guide covers the three validation approaches (controller, Form Request, Validator::make). The rules reference lists every built-in rule before you write your own. For conditional logic around when rules apply, see the conditional validation article. And for complex error message customization beyond what $fail()->translate() provides, the error messages guide has more detail.