The first time validation gets hard is the day a product manager says "the discount cannot exceed twenty percent of the order total, unless the customer is a VIP, and never on items that are already on sale."
Field-level rules — required, email, max:120 — handle the polite parts of input validation. They handle the shape. Business rules are different. They are about how fields relate to each other, what the database thinks, and what the product allows in this exact context. Laravel has tools for every layer of this, but they live in different parts of the framework and they look intimidating until you see them used together.
This is the toolkit, in roughly the order you reach for it.
Conditional Rules Without Closures
The simplest thing to learn first is that Laravel already understands "this field depends on that field." required_if, required_unless, required_with, required_without, required_with_all, prohibited_if, and prohibited_unless express most field-pair constraints in a single string.
public function rules(): array
{
return [
'account_type' => ['required', Rule::in(['personal', 'business'])],
'company_name' => ['required_if:account_type,business', 'string', 'max:120'],
'tax_id' => ['required_if:account_type,business', 'string'],
'pickup' => ['boolean'],
'shipping_addr' => ['required_unless:pickup,true', 'array'],
'discount_code' => ['prohibited_if:promo_locked,true', 'nullable', 'string'],
];
}
The trap to avoid: do not start composing rules with if ($this->input(...)) blocks inside rules(). The dependency-aware rules read the same input bag the validator does, and they compose with Rule::when() for anything they cannot express directly.
'plan' => [
'nullable',
Rule::when(
fn () => $this->input('account_type') === 'business',
['required', Rule::in(['team', 'enterprise'])],
),
],
Rule::when() (Laravel 8.55+) returns a rule set only when the closure resolves true. Two of these stacked in one rule beat fifteen lines of imperative branching.
Array And Nested Validation
Order forms, batch imports, multi-step wizards — most non-trivial APIs accept arrays of objects. Laravel's * syntax validates every entry without a loop:
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:100'],
'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')],
'items.*.qty' => ['required', 'integer', 'min:1', 'max:50'],
'items.*.unit_price' => ['required', 'numeric', 'min:0', 'decimal:0,2'],
'items.*.notes' => ['nullable', 'string', 'max:255'],
];
}
Two underused tricks live here. First, 'items.*.qty' => 'required' produces error messages keyed by exact path — items.3.qty — which makes form rendering on the client straightforward. Second, you can validate the whole array once and the items individually in the same array; Laravel will run both.
For nested objects with their own shape — say a shipping_address object — write the parent and the children explicitly:
'shipping_address' => ['required_unless:pickup,true', 'array'],
'shipping_address.line1' => ['required_unless:pickup,true', 'string'],
'shipping_address.country' => ['required_unless:pickup,true', 'string', 'size:2'],
The repetition is annoying but explicit, and the error messages map cleanly to a real form.
Database-Aware Rules — Rule::unique, Rule::exists
Rule::unique and Rule::exists are usually shown in their string form (unique:customers,email) and that form is fine for trivial cases. The fluent form is what you reach for in production:
'email' => [
'required',
'email:rfc,dns',
Rule::unique('customers', 'email')
->where(fn ($q) => $q->where('tenant_id', $this->user()->tenant_id))
->ignore($this->route('customer')),
],
Three things this gets right. ->where() scopes uniqueness to a tenant — a multi-tenant system rarely wants global uniqueness. ->ignore($id) is the difference between "create" working and "update" thinking the row is its own duplicate. And Rule::exists() takes the same ->where() and ->whereNull('deleted_at') style so soft-deleted rows do not silently satisfy a foreign-key check.
A small habit: never let unique or exists rules run on un-normalized input. Pair them with prepareForValidation so trim and lowercase happen first, otherwise User@example.com and user@example.com are two different uniqueness keys.
Custom Rule Classes
When the same domain check appears in three FormRequests, generate a rule class:
php artisan make:rule UniqueEmailDomain
From Laravel 10 onward the Rule interface has a single method:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class UniqueEmailDomain implements ValidationRule
{
public function __construct(private array $blocklist = []) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = strtolower((string) Str::after((string) $value, '@'));
if (in_array($domain, $this->blocklist, true)) {
$fail("The :attribute must not use a disposable email provider.");
}
}
}
Used like any other rule:
'email' => ['required', 'email', new UniqueEmailDomain(config('signup.email_blocklist'))],
If you need translatable messages, the validate() method also receives Translator via dependency injection if the class implements DataAwareRule or ValidatorAwareRule. For most cases, the $fail() callback with a :attribute placeholder is enough.
Cross-Field Business Rules — The after Callback
Some rules need to see the whole payload at once. A discount that cannot exceed the order total. A start date that must be before an end date. A booking that cannot overlap an existing one. Field-level rules cannot express these without resorting to a custom rule that reads the entire request.
The clean answer is withValidator() with an after() callback:
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
$items = collect($this->input('items', []));
$total = $items->sum(fn ($i) => ($i['qty'] ?? 0) * ($i['unit_price'] ?? 0));
if ((float) $this->input('discount', 0) > $total * 0.20) {
if (! $this->user()?->hasRole('vip')) {
$v->errors()->add(
'discount',
'Discounts above 20% require a VIP customer.'
);
}
}
$startsAt = $this->date('starts_at');
$endsAt = $this->date('ends_at');
if ($startsAt && $endsAt && $startsAt->gte($endsAt)) {
$v->errors()->add('ends_at', 'The end date must be after the start date.');
}
});
}
The after callback runs only after every other rule has passed. That ordering matters — you do not want to evaluate a cross-field rule against input that has not been shape-checked yet.
For overlap-style checks against the database (a booking that conflicts with an existing one), keep the query in the after callback rather than buried in a custom rule class. The query depends on multiple inputs at once (room_id, starts_at, ends_at), and a custom rule that takes one attribute will end up reading request() anyway.
When To Move The Rule Out Of Validation Entirely
There is a point where a "validation" rule is actually a domain invariant pretending to be one. The signal: the rule depends on data the user did not submit. "An order cannot be approved if its account is suspended" is not validation — that is a Policy. "A subscription cannot be downgraded with active add-ons" is not validation — that is a state-machine guard inside the Action.
The split that holds up in production:
- FormRequest validation — anything about the shape and basic constraints of the input, plus uniqueness/exists checks against the database.
- Policy / Gate — who is allowed to attempt the action.
- Action / domain service — invariants about the state of the entity being acted on, computed from data the user did not submit.
When a rule asks "is the request well-formed?" it belongs in rules() or after(). When it asks "is the entity in a state that allows this?", it belongs deeper. The temptation is to keep stacking validation because it returns a tidy 422 — but a 422 for a domain invariant lies to the client about what failed. A 409 Conflict or a 422 from the Action with a domain error code is more honest.
A Realistic FormRequest, End To End
Pulling everything into one example that has actually shipped — a "create order" request with conditional rules, nested arrays, database checks, custom messages, and a cross-field invariant:
final class StoreOrderRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'discount_code' => strtoupper(trim((string) $this->input('discount_code'))) ?: null,
]);
}
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => [
'required', 'integer',
Rule::exists('customers', 'id')
->where('tenant_id', $this->user()->tenant_id),
],
'pickup' => ['boolean'],
'shipping_address' => ['required_unless:pickup,true', 'array'],
'shipping_address.line1' => ['required_unless:pickup,true', 'string'],
'shipping_address.country' => ['required_unless:pickup,true', 'string', 'size:2'],
'items' => ['required', 'array', 'min:1', 'max:100'],
'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')],
'items.*.qty' => ['required', 'integer', 'min:1', 'max:50'],
'items.*.unit_price' => ['required', 'numeric', 'min:0', 'decimal:0,2'],
'discount_code' => [
'nullable', 'string', 'size:8',
Rule::when(
fn () => filled($this->input('discount_code')),
[Rule::exists('discount_codes', 'code')->where('active', true)],
),
],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
if ($this->input('items')) {
$total = collect($this->input('items'))
->sum(fn ($i) => ($i['qty'] ?? 0) * ($i['unit_price'] ?? 0));
if ($total > 50000 && ! $this->user()?->hasRole('enterprise')) {
$v->errors()->add(
'items',
'Orders over 50,000 require an enterprise account.'
);
}
}
});
}
public function messages(): array
{
return [
'items.required' => 'An order needs at least one line item.',
'items.*.qty.max' => 'No single line can exceed 50 units.',
'discount_code.exists' => 'That discount code is not active.',
];
}
}
Reading top to bottom: input is normalized, the user is authorized, every field is shape-checked, the database is queried for tenant-scoped existence, and a final cross-field rule enforces a business cap. The controller that consumes this never sees a malformed payload, and every failure lands at the right HTTP status with a message a human wrote.
Validation in Laravel scales when you stop thinking of rules() as the whole story. The framework gives you four layers — conditional rules, database-aware rules, custom rule classes, and the after callback — and they were designed to be used together.




