Form Requests are the most underrated thing in Laravel
Published April 18, 2026 · by Majd Ghithan, written with the help of AI
Most Laravel devs meet Form Requests once, in a tutorial, use them to hold a rules() array, and never look deeper. That's a shame, because a Form Request is the cleanest place in the whole framework to put everything that has to be true before your controller runs — and it does four separate jobs, not one.
I started taking them seriously the day a controller of mine had a permission check, a validation block, a Str::lower() on the email, and a "the end date must be after the start date" rule all tangled together at the top. Every one of those belonged somewhere else. All of them fit in a Form Request.
It's a class, not just a rules array
php artisan make:request StoreBookingRequest gives you a class with two methods. The mistake is thinking those two are all it has.
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Booking::class);
}
public function rules(): array
{
return [
'room_id' => ['required', 'exists:rooms,id'],
'starts_at' => ['required', 'date', 'after:now'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'guest_email'=> ['required', 'email'],
];
}
}
Type-hint it in the controller and Laravel does the rest: it resolves the class, runs authorize(), runs rules(), and only if both pass does your method get called. Fail authorization and it's a 403. Fail validation and it's a 422 with the error bag. Your controller never sees a bad request.
public function store(StoreBookingRequest $request)
{
// if we're here, it's authorized AND valid
$booking = Booking::create($request->validated());
return redirect()->route('bookings.show', $booking);
}
authorize() — the check that isn't validation
Authorization is not "is this data well-formed." It's "is this person allowed." Putting it in the Form Request means it runs before validation, which is the correct order — you shouldn't tell someone their email is malformed if they were never allowed to submit the form. Return false, or better, delegate to a Policy with $this->user()->can(...) so the rule lives in one place.
prepareForValidation() — fix the input before you judge it
This is the method almost nobody uses, and it's the one that cleaned up my controllers the most. It runs before rules(), so you normalize the input here and validate the clean version. Lowercasing an email, trimming a code, casting a checkbox, splitting a comma list — all of it belongs here, not scattered across the controller.
protected function prepareForValidation(): void
{
$this->merge([
'guest_email' => Str::lower(trim($this->guest_email ?? '')),
'coupon' => $this->coupon ? strtoupper($this->coupon) : null,
]);
}
Now email validation runs against an already-lowercased value, and everything downstream gets the normalized shape. There's a sibling, passedValidation(), that runs after — handy for a final touch once you know the data is good.
Custom rule objects — when a string rule isn't enough
The built-in rules cover most things. When they don't — a rule that has to hit the database, or check business state — write a Rule object instead of stuffing a closure inline.
class RoomIsAvailable implements ValidationRule
{
public function __construct(private string $startsAt, private string $endsAt) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$clash = Booking::where('room_id', $value)
->where('starts_at', '<', $this->endsAt)
->where('ends_at', '>', $this->startsAt)
->exists();
if ($clash) {
$fail('That room is already booked for those times.');
}
}
}
Wire it into rules() like any other rule:
'room_id' => ['required', 'exists:rooms,id',
new RoomIsAvailable($this->starts_at, $this->ends_at)],
The overlap logic now has a name and a test of its own, instead of being a mystery where clause buried in a controller.
Why the controller ends up empty
Look at what left the controller and where it went:
public function store(Request $r)
{
if (! $r->user()->can(...))
abort(403);
$r->merge(['email' =>
strtolower($r->email)]);
$data = $r->validate([...]);
if (Booking::overlaps(...))
return back()->withErrors(...);
Booking::create($data);
}
// StoreBookingRequest handles:
// authorize() -> the 403
// prepareForValidation -> lowercase
// rules() -> the shape
// RoomIsAvailable rule -> overlap
public function store(
StoreBookingRequest $r
) {
Booking::create(
$r->validated()
);
}
Four jobs — authorize, normalize, validate, apply business rules — all moved out of the controller and into a class whose entire reason to exist is guarding the door. The controller does the one thing it should: it trusts that if it's running at all, the request was already checked.
Form Requests aren't just tidy. They're the framework quietly offering you a place for the four things every write endpoint needs, and most of us only ever use one of them.
