•4 min read

Laravel Form Request Validation: The Patterns I Actually Use

Last week I reviewed a pull request from a team that just inherited a Laravel codebase, and one controller line made me wince: Post::create($request->all()). It worked. It had worked for two years. It was also one spicy payload away from writing whatever columns a client felt like sending. That single line is why I keep insisting on form request validation, because the class is not really about tidy rules arrays. It is about drawing a hard line between what comes in and what touches your database.

A developer typing form validation code on a laptop at a desk

validated() is the whole point

Most tutorials show you rules() and stop there. The part that actually protects you is what you read back out in the controller:

public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:120'],
        'body' => ['required', 'string', 'min:50'],
        'tags' => ['array', 'max:5'],
        'tags.*' => ['string', 'max:20'],
    ];
}
public function store(StorePostRequest $request): RedirectResponse
{
    $post = $request->user()->posts()->create($request->validated());

    return redirect()->route('posts.show', $post);
}

validated() returns only the keys that have rules. Anything extra in the payload, a stray is_admin, a hidden user_id, gets dropped before it reaches Eloquent. Pair this with Rule::unique() handling and the mass assignment errors I wrote about earlier mostly evaporate. I ran into the same habit myself, honestly. Years of typing $request->input('title') is a tough reflex to break, but every field you pull that way skipped the checks you just wrote. If you need a subset, $request->safe()->only(['title', 'body']) gives you a filtered bag instead of raw input.

One more detail people miss: the fields in rules() are the whitelist, so if you validate a field but never want it persisted, add exclude style rules like 'terms' => ['accepted', 'exclude']. It passes validation and disappears from the validated output.

sometimes and nullable are not the same thing

I see these swapped constantly on PATCH endpoints. sometimes means: skip every rule on this field if the key is absent from the request. nullable means: the field may be null, but the rest of the rules still run when it is present.

'bio' => ['sometimes', 'string', 'max:500'],
'website' => ['nullable', 'url', 'max:255'],

With sometimes, omitting bio leaves the old value alone. Sending "bio": null still fails the string check, which surprises people. With nullable, an explicit null sails through and your model writes it. Get this wrong and either your partial updates blow up or your "clear this field" feature silently does nothing. I have debugged both flavors more than once.

prepareForValidation cleans up before the rules run

Clients send messy data. Empty strings where you expect null, slugs with random casing, phone numbers full of spaces. Instead of throwing rules at the mess, normalize it first:

protected function prepareForValidation(): void
{
    $this->merge([
        'slug' => Str::slug($this->input('slug') ?: $this->input('title', '')),
        'phone' => preg_replace('/\s+/', '', (string) $this->input('phone')),
    ]);
}

Now the rules run against clean input and you do not need a sometimes flag on a field that only exists half the time. I used to do this normalization in the controller. Then I had three endpoints that all created posts and only one of them generated slugs. Fun bug to find in production.

Unique rules break on updates

Everyone writes Rule::unique('users') for registration and then discovers their profile update form rejects the user's own email. You have to ignore the current record:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'email' => [
            'required',
            'email:rfc',
            Rule::unique('users')->ignore($this->user()),
        ],
    ];
}

Passing the model works, passing the id works, but be careful which id you pass. $this->route('user') pulls it from the route parameter, which matters when an admin edits someone other than themselves. I once ignored the wrong record in a multi-tenant admin panel and the duplicate-email check effectively stopped existing for those requests. The database unique index caught it eventually. The index is the last line of defense, not the first, so keep both.

When to pull logic out into a rule object

Closures inside rules() are fine for one-off checks. Once the same check appears in a second form request, or it needs a constructor argument, it earns its own class:

use Illuminate\Contracts\Validation\ValidationRule;

class ActiveCoupon implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $coupon = Coupon::where('code', $value)->first();

        if (! $coupon || $coupon->expires_at?->isPast()) {
            $fail('That coupon code is invalid or has expired.');
        }
    }
}

Now it is unit-testable without spinning up an HTTP request, and your rules array reads like a sentence. My cutoff is simple: closure in one place, object in two. Database queries in inline closures are the thing I rip out fastest during refactors, because they hide Eloquent calls inside a giant array where nobody thinks to look for them.

None of this is clever. That is sort of the point. Form requests are the boring scaffolding that keeps $request->all() out of your codebase, and boring is what you want between raw client input and your tables. I keep the snippets for these patterns in Snippet Ark since they repeat on every Laravel project I start, and pasting the skeleton in takes ten seconds versus reconstructing it from memory.

If you want the companion piece, the mass assignment post pairs directly with this one. Validation decides what gets in. $fillable decides what gets written. You want both doors locked.