You open a Laravel controller and find this:
public function store(Request $request)
{
$data = $request->validate([
'email' => 'required|email',
'name' => 'required|string',
]);
if (! $request->user()->can('create', Customer::class)) {
abort(403);
}
$data['email'] = strtolower(trim($data['email']));
$data['referral_code'] = strtoupper($data['referral_code'] ?? '');
$customer = Customer::create($data);
Log::info('customer.created', ['id' => $customer->id]);
return new CustomerResource($customer);
}
Nothing here is wrong on its own. It runs. It tests. It will probably ship. But the controller is doing four jobs: preparing input, checking permission, validating shape, and writing audit data. Six months from now this same logic will live behind two more endpoints, an Artisan command, and a webhook, and one of those copies will quietly disagree about whether the email gets lowercased.
FormRequest exists to fix this — and most teams use about a third of what it can do.
The Full Lifecycle, Not Just rules()
Every FormRequest runs through four hooks, in this order, before your controller ever gets called:
prepareForValidation()— mutate the input before any rules run.authorize()— return a bool deciding whether this user is allowed to even attempt the action.rules()— the dictionary you already know.passedValidation()— runs after all rules pass and before the controller, with$this->validated()available.
There is also failedValidation() for custom error responses, messages() for human-readable strings, attributes() for nicer field names, and withValidator() to attach closure-based rules. That is six hooks, and most controllers I review only ever override one.
The shape that earns its rent looks like this:
// app/Http/Requests/StoreCustomerRequest.php
namespace App\Http\Requests;
use App\Models\Customer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreCustomerRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim((string) $this->input('email'))),
'referral_code' => strtoupper((string) $this->input('referral_code', '')),
]);
}
public function authorize(): bool
{
return $this->user()?->can('create', Customer::class) ?? false;
}
public function rules(): array
{
return [
'email' => ['required', 'email:rfc,dns', Rule::unique('customers', 'email')],
'name' => ['required', 'string', 'max:120'],
'referral_code' => ['nullable', 'string', 'size:8'],
];
}
protected function passedValidation(): void
{
Log::info('customer.create.validated', [
'email' => $this->validated()['email'],
'ip' => $this->ip(),
]);
}
}
Now the controller is one line of work — Customer::create($request->validated()) — and every concern lives in the place its name promises.
prepareForValidation Is Where Input Becomes Predictable
The most useful hook is also the most underused. Any time you find yourself writing trim(), strtolower(), or Carbon::parse() inside a controller, that work belongs in prepareForValidation. The benefit is not just tidiness — it changes what the rules can do.
Consider an Rule::unique('customers', 'email') rule. If your input has a trailing space and uppercase letters, the unique check passes, you save the row, and the next time the same human signs up they create a duplicate because their email got normalized somewhere else. Mutating the input before the rule runs makes the rule honest.
protected function prepareForValidation(): void
{
$this->merge([
'starts_at' => $this->filled('starts_at')
? Carbon::parse($this->input('starts_at'))->utc()->toIso8601String()
: null,
'tags' => collect((array) $this->input('tags'))
->filter()->map(fn ($t) => Str::slug($t))->values()->all(),
]);
}
Two principles hold up here. Mutate, don't reject — rejection is the rules' job. And keep prepareForValidation cheap; it runs on every request including ones that fail validation.
authorize() Is Not Just A Boolean
The single most common pattern I see is return true; everywhere because "the route already has middleware." That works until the same FormRequest gets used in a console command or a queued job, and your authorization disappears.
The shape that survives is to delegate to a Policy:
public function authorize(): bool
{
return $this->user()?->can('update', $this->route('order')) ?? false;
}
Two things this gets right. First, the rule lives in OrderPolicy::update(), which is the same place a Blade @can directive checks, which is the same place a controller's $this->authorize() call would check. Second, when authorization fails, Laravel returns a 403 — and the validation rules never run. That is the difference between "you cannot edit this order" and "your edit was malformed". They are different bugs and they deserve different status codes.
If authorize() returns false, override failedAuthorization() for a custom message; otherwise the default AuthorizationException is fine.
Conditional Rules Without if Soup
Real validation almost always has shape. A subscription_plan is required only if account_type === 'business'. A shipping_address is required only when pickup is not selected. The naive way is to read $this->input(...) and build different rule arrays — and that scales badly.
Laravel ships three good tools.
public function rules(): array
{
return [
'account_type' => ['required', Rule::in(['personal', 'business'])],
'tax_id' => ['required_if:account_type,business', 'string'],
'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'],
'plan' => [
'nullable',
Rule::when(
fn () => $this->input('account_type') === 'business',
['required', Rule::in(['team', 'enterprise'])],
),
],
];
}
required_if, required_unless, required_with, required_without, and prohibited_if cover most field-pair logic without a single closure. Rule::when() (Laravel 8.55+) wraps any rule set behind a condition. And for nested arrays, items.*.qty => 'required|integer|min:1' lets you validate every line item with one rule.
The rule of thumb: if the same condition appears in three rules, the input shape is wrong. Reach for prepareForValidation to normalize it before validation runs.
withValidator And Custom Rule Classes
Sometimes a rule depends on the whole payload — a discount that cannot exceed the order total, a date range that must not overlap an existing booking. The validator instance gives you after() for that:
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
$total = collect($this->input('items', []))
->sum(fn ($i) => ($i['qty'] ?? 0) * ($i['unit_price'] ?? 0));
if (($this->input('discount', 0)) > $total) {
$v->errors()->add('discount', 'Discount cannot exceed the order total.');
}
});
}
For rules you reuse, generate a class: php artisan make:rule UniqueEmailDomain. From Laravel 10 onward the interface uses a single validate(string $attribute, mixed $value, Closure $fail) method:
final class UniqueEmailDomain implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = Str::after((string) $value, '@');
if (in_array($domain, ['mailinator.com', 'guerrillamail.com'], true)) {
$fail("The :attribute must not use a disposable email provider.");
}
}
}
Use it like any other rule: 'email' => ['required', 'email', new UniqueEmailDomain].
passedValidation Is Not A Place For Business Logic
This is where the temptation gets dangerous. passedValidation() runs after rules pass and before the controller, and it is so convenient that people start writing model writes inside it. Don't.
A clean line: passedValidation can normalize, log, or enrich the validated payload. It cannot create rows, dispatch events, or call external APIs. The reason is consistency — controllers and Actions are the things you test for behavior, and a row created from a validation hook will not show up in any of the boundaries a reviewer expects.
protected function passedValidation(): void
{
$this->merge([
'validated_at' => now()->toIso8601String(),
'idempotency_key' => $this->header('Idempotency-Key', (string) Str::uuid()),
]);
}
Adding metadata is fine. Calling Customer::create(...) is not.
Custom Messages And Field Names That Match The Product
The default error messages are usable, but they say "field" when your users say "company". Two methods fix that without changing rules:
public function messages(): array
{
return [
'email.unique' => 'A customer with that email already exists.',
'tax_id.required_if' => 'A tax ID is required for business accounts.',
];
}
public function attributes(): array
{
return [
'tax_id' => 'tax ID',
'starts_at' => 'start date',
'shipping_address.line1' => 'street address',
];
}
If you have product-wide message conventions, set them in lang/en/validation.php once and stop overriding. The attributes() method exists exactly so you do not have to.
What This Buys You In The Controller
Everything above lets the controller shrink to something almost embarrassingly small:
final class StoreCustomerController
{
public function __invoke(StoreCustomerRequest $request): CustomerResource
{
return CustomerResource::make(
Customer::create($request->validated())
);
}
}
That is the win. The HTTP boundary owns input shape, permission, and normalization. The controller wires a validated payload to a write. The next time someone needs to create a customer from a queued import, they instantiate StoreCustomerRequest::createFrom($source), call validate(), and reuse every rule, message, and lifecycle hook with no copy-paste.
FormRequest is the part of Laravel where the framework actually rewards you for using more of it. The four hooks are not ceremony — they are the difference between an HTTP layer that decides things and a controller that quietly accumulates them.



