Form Requests: Getting Validation Out of the Controller

Form Requests: Getting Validation Out of the Controller

October 5, 2026
The same validation rules copied into four places and drifting apart.

A controller method that stores an invoice should read like storing an invoice. Most of them read like a validation specification with a database call at the bottom.

public function store(Request $request)
{
    $data = $request->validate([
        'customer_id' => ['required', 'exists:customers,id'],
        'due_date'    => ['required', 'date', 'after:today'],
        'items'       => ['required', 'array', 'min:1'],
        'items.*.description' => ['required', 'string', 'max:255'],
        'items.*.qty' => ['required', 'integer', 'min:1'],
        'items.*.rate'=> ['required', 'numeric', 'min:0'],
        // ...twenty more lines
    ]);

    $invoice = Invoice::create($data);       // the actual work
    return redirect()->route('invoices.show', $invoice);
}

Then the update method gets a copy with two rules changed, the API controller gets a third copy, and an import command gets a fourth that has drifted. Nobody decided that; it is what happens when the rules live where they are used rather than where they belong.

The same validation rules copied into four places and drifting apart.
Nobody decided this — it is what happens when rules live where they are used

The move itself

php artisan make:request StoreInvoiceRequest
class StoreInvoiceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Invoice::class);
    }

    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'exists:customers,id'],
            'due_date'    => ['required', 'date', 'after:today'],
            'items'       => ['required', 'array', 'min:1'],
            'items.*.qty' => ['required', 'integer', 'min:1'],
        ];
    }
}
public function store(StoreInvoiceRequest $request)
{
    $invoice = Invoice::create($request->validated());
    return redirect()->route('invoices.show', $invoice);
}

Laravel resolves the request, runs authorize(), then rules(), and the controller method only runs if both passed. The controller is now two lines and neither is about validation.

The ordering matters: authorisation runs first, so a user who may not create an invoice gets a 403 without being told which of their fields were wrong. Validation errors are information, and a user who is not allowed to act should not receive it.

validated(), not all()

This is the single line most worth being strict about.

Invoice::create($request->all());          // everything the client sent
Invoice::create($request->validated());    // only what you asked for

all() returns every field in the payload, including ones you never validated. Combined with a model that is loosely fillable, that is how a request containing "status": "paid" or "organization_id": 7 gets written to the database.

$fillable is a second line of defence and it is a list somebody maintains by hand, which means it lags. validated() is derived from the rules, so it cannot fall behind them.

Every field in the payload is attacker-controlled. validated() is the boundary where that stops being true.

For a subset there are safe()->only([...]) and safe()->except([...]), which read better than array juggling and keep the same guarantee.

Cleaning input before the rules see it

prepareForValidation runs before the rules and is where normalisation belongs. It is the hook most codebases never discover, and it removes a lot of defensive code from elsewhere.

protected function prepareForValidation(): void
{
    $this->merge([
        'email'  => strtolower(trim((string) $this->email)),
        'phone'  => preg_replace('/D/', '', (string) $this->phone),
        'slug'   => $this->slug ?: Str::slug((string) $this->title),
    ]);
}

Without it, every rule has to tolerate untrimmed input and every consumer has to re-normalise. With it, the rest of the application can assume an email is lowercase because there is exactly one place that could have made it otherwise.

Careful with one thing: merge overwrites, so a conditional normalisation needs the condition inside the hook rather than around it. Writing 'phone' => preg_replace(...) when phone was absent turns a missing field into an empty string, and required then fails with a confusing message instead of the right one.

Rules that depend on other fields

Three levels of conditionality, in increasing order of how often they are needed.

// Built-in conditional rules cover most cases
'card_number' => ['required_if:payment_method,card'],
'bank_name'   => ['required_unless:payment_method,card'],
'ends_at'     => ['nullable', 'date', 'after:starts_at'],
// withValidator, for rules the array cannot express
public function withValidator(Validator $v): void
{
    $v->sometimes('discount', ['max:50'], fn ($input) => $input->customer_tier === 'standard');
}
// after(), for a check that needs the database or several fields at once
public function after(): array
{
    return [
        function (Validator $v) {
            $total = collect($this->items)->sum(fn ($i) => $i['qty'] * $i['rate']);
            if ($total > $this->user()->organization->credit_limit) {
                $v->errors()->add('items', 'This invoice exceeds your credit limit.');
            }
        },
    ];
}

The after hook is the right home for business rules that happen to be validation — a credit limit, a double booking, a quantity exceeding stock. Those often end up in the controller or, worse, in a model event where a failure is much harder to report back to the form.

The four hooks in a form request and what each is for.
prepareForValidation and after() are the two most codebases never discover

Store and update without copying the rules

The usual solution is a base class, and it is worth being careful about which parts differ.

abstract class InvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'exists:customers,id'],
            'due_date'    => ['required', 'date'],
            'items'       => ['required', 'array', 'min:1'],
        ];
    }
}

class StoreInvoiceRequest extends InvoiceRequest
{
    public function authorize(): bool { return $this->user()->can('create', Invoice::class); }

    public function rules(): array
    {
        return array_merge(parent::rules(), [
            'due_date' => ['required', 'date', 'after:today'],   // only on create
        ]);
    }
}

class UpdateInvoiceRequest extends InvoiceRequest
{
    public function authorize(): bool { return $this->user()->can('update', $this->route('invoice')); }
}

The uniqueness rule is where this most often goes wrong. On update, a record must be allowed to keep its own value:

'email' => ['required', 'email', Rule::unique('users')->ignore($this->route('user'))],

Without ignore, saving a user without changing their email fails validation on their own address, which is a bug reported as “I cannot save” with no obvious cause.

Rules worth extracting

When the same validation appears in several requests, a rule object keeps it in one place and makes it testable on its own.

class IndianMobile implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $digits = preg_replace('/D/', '', (string) $value);
        $digits = preg_replace('/^(91|0)/', '', $digits);

        if (!preg_match('/^[6-9]d{9}$/', $digits)) {
            $fail('The :attribute must be a valid 10-digit mobile number.');
        }
    }
}

// 'phone' => ['required', new IndianMobile],

The normalisation inside it is deliberate. A phone rule that rejects +91 98765 43210 because of the spaces is technically correct and produces a support ticket. Strip first, then judge.

Laravel’s own tel-style checks are permissive enough to accept almost anything, so a rule of your own is usually the only real validation a phone number gets.

Errors the user can act on

Default messages are serviceable and generic. Two overrides cost little and improve the form noticeably.

public function messages(): array
{
    return [
        'items.required'   => 'An invoice needs at least one line item.',
        'due_date.after'   => 'The due date has to be in the future.',
        'items.*.qty.min'  => 'Quantity must be at least 1.',
    ];
}

public function attributes(): array
{
    return ['customer_id' => 'customer', 'items.*.rate' => 'rate'];
}

attributes() is the cheaper of the two: it fixes every message for that field at once, so “The customer id field is required” becomes “The customer field is required” everywhere without writing a message per rule.

For an API, overriding failedValidation lets you shape the error envelope to match the rest of your responses rather than accepting Laravel’s default 422 body.

Where each concern belongs once validation leaves the controller.
One place to change means the store and update paths cannot drift

Testing the request rather than the route

A form request is a class, so it can be tested without HTTP — which makes testing dozens of edge cases fast.

private function validate(array $data): IlluminateValidationValidator
{
    $request = new StoreInvoiceRequest;
    return Validator::make($data, $request->rules());
}

public function test_due_date_must_be_in_the_future(): void
{
    $this->assertTrue($this->validate(['due_date' => now()->subDay()->toDateString()])
        ->errors()->has('due_date'));
}

That covers rules(). It does not cover authorize(), prepareForValidation or the after hooks, because those need a real request — so keep one HTTP test per endpoint for the wiring, and use the unit tests for the matrix of field cases.

The split is worth stating: unit tests for whether the rules are right, one HTTP test for whether they are connected.

The same rules from a controller, a command and a job

A form request is bound to an HTTP request, which is why the import command ends up with a fourth copy of the rules. Two ways out, depending on how much the paths really share.

The cheap one is to call rules() from wherever you need it, since it is an ordinary method:

$rules = (new StoreInvoiceRequest)->rules();
$data  = Validator::make($row, $rules)->validate();

That works as long as rules() does not touch $this->route() or $this->user(), which it does the moment you add a unique-ignore or a tier-dependent limit. Instantiating a form request outside a request cycle gives you an object with no route and no user, and those calls return null rather than failing loudly — so the rule quietly becomes a different rule.

The honest version is to move the rules to a plain class and have both callers ask it:

class InvoiceRules
{
    public static function base(): array { return [...]; }

    public static function forCreate(): array
    {
        return array_merge(self::base(), ['due_date' => ['required', 'date', 'after:today']]);
    }
}

The form request then returns InvoiceRules::forCreate() and the command calls the same method. It is one more file, and it is the only arrangement where the web path and the import path cannot disagree.

Files, and the rules people forget

Upload validation is where a form request most often looks complete and is not.

'logo' => ['required', File::image()->max(2 * 1024)->dimensions(
    Rule::dimensions()->maxWidth(2000)->maxHeight(2000)
)],
'attachment' => ['required', File::types(['pdf'])->max(2 * 1024)],

Three things are worth knowing. image checks the MIME type the server derives from the file contents, not the extension the client supplied, so a .jpg that is really a PHP script fails — but mimes:jpg and mimetypes:image/jpeg are different rules and only one of them reads the file. max is in kilobytes, which is a reliable source of off-by-1024 errors. And svg passes an image check while being a document that can carry script, so it belongs on an allow-list only when you are prepared to sanitise it.

The other half is outside validation entirely: PHP’s own upload_max_filesize and post_max_size cut in before Laravel sees the request. Exceed post_max_size and the request arrives with an empty $_POST, so the form request reports every field as missing rather than reporting the file as too large. It is worth checking those two ini values match whatever your max rule promises, or the error the user gets will be nonsense.

Arrays, and the error message nobody can read

Validating an array of line items is the case where the defaults stop being good enough. Laravel reports errors keyed by index — items.3.qty — and if the form does not map that back to the fourth row visually, the user is told something is wrong and not where.

@error('items.' . $i . '.qty')
    <span class="field-error">{{ $message }}</span>
@enderror

Rendering the error beside its own row costs one line in the loop and is the difference between a form people can fix and a form they abandon. The same applies to an API: returning the flat errors object is correct, and a client that does not walk the dotted keys will show none of it.

Two array rules are worth knowing beyond array and min:1. distinct catches the same value repeated across rows, which is what you want for a list of email addresses and not what you want for quantities. And Rule::array(['qty', 'rate', 'description']) — or exclude_unless — restricts which keys are accepted inside each item, which matters for the same reason validated() matters: without it, extra keys inside the nested arrays survive into the data you pass to create.

There is one behaviour that surprises people: rules on items.*.qty simply do not run when items is absent or empty. The validation passes, the controller receives an invoice with no lines, and the failure happens two steps later. That is what 'items' => ['required', 'array', 'min:1'] on the parent is for, and it is easy to leave out once the wildcard rules look thorough.

When a form request is the wrong tool

Three cases, so the pattern does not get applied where it costs more than it saves.

A one-field endpoint. A toggle route with 'enabled' => ['required', 'boolean'] does not need a class. Inline $request->validate() is honest there, and a dedicated file per trivial endpoint is the kind of consistency that makes a codebase harder to read, not easier.

Validation that belongs to the domain. If the same rule must hold whether the data arrived over HTTP, from a queue job or from a seeder, a form request cannot enforce it — it only exists during a request. That rule belongs in the model or a domain service, with the form request duplicating it only to give the user a good error message.

Data shaped by something else. Webhooks and third-party callbacks arrive in a format you do not control and often need transformation before validation makes sense. A DTO or a dedicated parser reads better than a form request whose prepareForValidation has grown into a translation layer.

The test is roughly: does this endpoint accept a payload a user typed, and does more than one path accept the same shape? If yes to both, a form request pays for itself immediately.

What it adds up to

Rules in one class per action, authorisation delegated to a policy, normalisation in prepareForValidation, business checks in after(), and controllers that contain only the work.

The measurable result is that the store and update paths cannot drift, because there is one place to change. The unmeasurable one is that a controller you open six months later tells you what it does in the first line, rather than making you read forty lines of rules to find out.