Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Form Requests: Validation That Keeps Your Controllers Clean

About Post

Open almost any Laravel project that's a few years old and find the biggest controller. I'll bet the store() method starts with a $request->validate([...]) block the size of a small novel, followed by some manual permission checks, a bit of input clean-up, and finally, somewhere near the bottom, the two lines that actually do the work.

It works. It's also the reason nobody wants to touch that controller.

Form requests are Laravel's answer, and most people only use a fraction of what they can do. Let's go through the whole thing with one realistic example: a tenant submitting a maintenance ticket from an app.

The basic move: rules out of the controller

Generate the class:

php artisan make:request StoreTicketRequest

Then move the rules in:

class StoreTicketRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Ticket::class);
    }

    public function rules(): array
    {
        return [
            'unit_id' => ['required', 'integer', 'exists:units,id'],
            'category' => ['required', Rule::in(['plumbing', 'electrical', 'ac', 'other'])],
            'priority' => ['required', 'in:low,normal,high'],
            'description' => ['required', 'string', 'max:2000'],
            'preferred_date' => ['nullable', 'date', 'after_or_equal:today'],
        ];
    }
}

And the controller shrinks to what it should have been all along:

public function store(StoreTicketRequest $request): RedirectResponse
{
    $ticket = $request->user()->tickets()->create($request->validated());

    return to_route('tickets.show', $ticket);
}

By the time your method runs, the request has already been authorized and validated. If validation fails, Laravel redirects back with the errors for a normal form, or returns a 422 JSON response for an API call. You don't write either path.

authorize(): the gotcha everyone hits once

The generated stub has an authorize() method that returns false. So the first time you wire up a new form request, every submission gets a 403 This action is unauthorized, and you spend ten confused minutes checking your routes.

Don't just flip it to true and move on. This method is a great place for the "is this user allowed to do this at all?" question, ideally by asking a policy rather than hard-coding a role check. For updates, you can reach the route-bound model directly:

public function authorize(): bool
{
    return $this->user()->can('update', $this->route('ticket'));
}

If your project handles all authorization in policies called from the controller, it's fine to return true here. Just make it a decision, not an accident.

Messages people can actually understand

"The unit id field is required" is technically correct and slightly robotic. Two small methods fix it:

public function messages(): array
{
    return [
        'unit_id.exists' => 'We could not find that unit. Please pick it from the list.',
        'preferred_date.after_or_equal' => 'Please choose today or a later date.',
    ];
}

public function attributes(): array
{
    return ['unit_id' => 'unit', 'preferred_date' => 'preferred visit date'];
}

attributes() renames the field in every default message, so you don't have to rewrite them one by one. This matters more than it sounds when the messages are shown in a mobile app to people who have never seen a database column.

prepareForValidation(): clean the input first

Real clients send messy data. One app sends "High", another sends "high". A checkbox arrives as "on", "1" or not at all. A phone number has spaces and dashes.

Instead of making your rules tolerate all of that, normalise it before the rules run:

protected function prepareForValidation(): void
{
    $this->merge([
        'priority' => strtolower((string) $this->input('priority', 'normal')),
        'is_urgent' => $this->boolean('is_urgent'),
        'contact_phone' => preg_replace('/\D+/', '', (string) $this->input('contact_phone')),
    ]);
}

Two things to know. First, Laravel already trims strings and converts empty strings to null through its default middleware, so you don't need to do that here. Second, a field you merge in still needs a rule if you want it in validated(). Which brings us to the most important habit.

Use validated(), not all()

$request->validated() returns only the fields that have rules. $request->all() returns whatever the client sent, including the is_admin or status field someone added with their browser's dev tools.

When you need only part of the data, safe() is neat:

$ticket->update($request->safe()->only(['priority', 'description']));
$date = $request->validated('preferred_date');

Rule: if data goes into create() or update(), it comes from validated() or safe(). Mass-assignment protection on the model is your second line of defence, not your first.

after(): the checks that need more than one field

Some rules don't fit in a single field's array. "There's already an open ticket for this unit and category" depends on two inputs and a database lookup. The after() method returns callbacks that run once the normal rules are done:

use Illuminate\Validation\Validator;

public function after(): array
{
    return [
        function (Validator $validator) {
            if ($validator->errors()->isNotEmpty()) {
                return; // basic rules failed, skip the expensive check
            }

            $duplicate = Ticket::where('unit_id', $this->input('unit_id'))
                ->where('category', $this->input('category'))
                ->whereNull('closed_at')
                ->exists();

            if ($duplicate) {
                $validator->errors()->add('category', 'There is already an open request for this issue.');
            }
        },
    ];
}

That early return is the detail people miss. After-callbacks run even when earlier rules have failed, so without it you'd be querying with a unit_id that might be empty or invalid.

A few smaller tricks

  • protected $stopOnFirstFailure = true; stops at the first failing field, handy for cheap-then-expensive validation.
  • passedValidation() runs after everything passes, a good place to transform validated data (for example, combining a date and a time field).
  • One request per action. StoreTicketRequest and UpdateTicketRequest usually differ in small but important ways. Resist the urge to share one class with if ($this->isMethod('post')) branches.
  • Test them through HTTP. A feature test that posts bad data and asserts assertInvalid(['unit_id']) covers the rules, messages and wiring in one go.

The checklist

  • Rules live in the form request, not the controller.
  • authorize() is a real decision, ideally backed by a policy.
  • Messy input gets normalised in prepareForValidation().
  • Cross-field and database checks go in after(), with an early return.
  • Only validated() or safe() data touches your models.

Your controller then reads like a sentence: authorized, validated, create, redirect. The next developer to open it will quietly thank you.

What's the strangest piece of input clean-up you've had to write for a real client app?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close