🚦 Form Request Classes
As validation grows, a fat controller becomes hard to read and impossible to reuse. Form Request classes pull validation, authorization, and input preprocessing into their own dedicated class — a single gatekeeper for one entry point. This lesson explores their lifecycle, hooks, composition patterns, and testing.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Generate a Form Request and explain each of its lifecycle hooks
- Combine authorization and validation in one class with
authorize()andrules() - Reshape input with
prepareForValidation()andpassedValidation() - Compose requests using base classes and traits to eliminate duplication
- Return custom failure responses for APIs via
failedValidation() - Test a Form Request's rules and authorization
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a base request plus two child requests that share rules, and write a feature test for them.
In This Lesson
Why Form Requests?
A Form Request is a dedicated class that guards one entry point into your application. It answers two questions before your controller runs a single line: "Is this user allowed to do this?" (authorization) and "Is the data they sent acceptable?" (validation). Think of it as a specialized gatekeeper — each request class is designed to protect exactly one door with its own credentials check and rulebook.
The payoff is a controller that reads like a summary of intent, with all the guarding moved aside:
// Before: validation clutters the controller
public function store(Request $request)
{
$validated = $request->validate([ /* ...many rules... */ ]);
if (! $request->user()->can('create', Post::class)) abort(403);
// ...actual work buried below...
}
// After: the request class handles guarding; the controller states intent
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
Type-hint the request class on the method and Laravel does the rest: it resolves the class, runs authorize(), then validation, and only then calls your controller — which by that point can trust every byte it receives.
💡 Generate one with Artisan
php artisan make:request StorePostRequest
This creates app/Http/Requests/StorePostRequest.php with stub authorize() and rules() methods ready to fill in.
Anatomy of a Request
A fully-featured Form Request exposes several methods. You implement only the ones you need — most classes use just authorize() and rules().
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
// 1. May this user make the request at all?
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
// 2. The validation rules
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
];
}
// 3. Override specific error messages (optional)
public function messages(): array
{
return ['email.unique' => 'This email address is already in use.'];
}
// 4. Rename fields in messages (optional)
public function attributes(): array
{
return ['email' => 'email address'];
}
// 5. Reshape input BEFORE validation runs (optional)
protected function prepareForValidation(): void
{
$this->merge([
'name' => trim($this->name),
'email' => strtolower($this->email),
]);
}
}
The Request Lifecycle
Understanding the order these hooks fire is the key to using them correctly. Here's the journey from raw HTTP request to your controller:
Two subtleties worth remembering:
prepareForValidation()runs first, even before authorization. It's your chance to normalize input (trim, lowercase, cast) so that both the auth check and the rules see clean data.authorize()is a hard gate. Returnfalseand the request dies with a 403 before any rule runs — an unauthorized user never even learns whether their data was valid.
Preparing & Post-processing
prepareForValidation() — clean input first
Run this to reshape the payload before rules evaluate it. Generate a slug, split a comma-separated string into an array, or normalize a date format:
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
protected function prepareForValidation(): void
{
// Derive a slug from the title if one wasn't supplied
if ($this->filled('title') && ! $this->filled('slug')) {
$this->merge(['slug' => Str::slug($this->title)]);
}
// Turn "php, laravel, api" into ['php', 'laravel', 'api']
if (is_string($this->tags)) {
$this->merge(['tags' => array_map('trim', explode(',', $this->tags))]);
}
// Normalize a date so the `date_format` rule sees what it expects
if ($this->filled('birth_date')) {
try {
$this->merge([
'birth_date' => Carbon::createFromFormat('m/d/Y', $this->birth_date)->format('Y-m-d'),
]);
} catch (\Throwable $e) {
// leave it as-is; validation will report the bad format
}
}
}
passedValidation() — act on clean data
Fires after all rules pass. Use it sparingly for lightweight transforms on validated data. Note: hashing a password is better done in the model (via an attribute cast) or the controller, but the hook is available:
protected function passedValidation(): void
{
// Example: attach a derived field once we know the input is valid
$this->merge(['ip_at_signup' => $this->ip()]);
}
⚠️ Only merge()-ed keys survive to validated()
validated() returns only fields that appear in your rules(). If prepareForValidation() adds a slug, add a 'slug' => '...' rule too, or it won't be included in the validated array your controller receives.
Composing Requests
As apps grow, the same rules appear across create and update requests. Two clean ways to share: an abstract base class, or a trait.
Base class inheritance
// app/Http/Requests/BaseUserRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
abstract class BaseUserRequest extends FormRequest
{
protected function baseRules(): array
{
return [
'name' => 'required|string|max:255',
'phone' => 'nullable|string|min:10',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'name' => trim($this->name),
'email' => strtolower(trim($this->email)),
]);
}
}
// Create: email must be globally unique, password required
class StoreUserRequest extends BaseUserRequest
{
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
public function rules(): array
{
return array_merge($this->baseRules(), [
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
}
}
// Update: ignore this user's own row; password optional
class UpdateUserRequest extends BaseUserRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
public function rules(): array
{
$userId = $this->route('user')->id;
return array_merge($this->baseRules(), [
'email' => "required|email|unique:users,email,{$userId}",
'password' => 'nullable|min:8|confirmed',
]);
}
}
Traits for cross-cutting rules
When shared rules span classes that don't share a parent, a trait fits better than inheritance:
// app/Http/Requests/Concerns/HasAddressRules.php
namespace App\Http\Requests\Concerns;
trait HasAddressRules
{
protected function addressRules(): array
{
return [
'address_line1' => 'required|string|max:100',
'city' => 'required|string|max:50',
'zip_code' => 'required|string|max:20',
'country' => 'required|string|size:2', // ISO code
];
}
}
// Any request can mix it in
class StoreOrderRequest extends FormRequest
{
use HasAddressRules;
public function rules(): array
{
return array_merge($this->addressRules(), [
'items' => 'required|array|min:1',
'items.*' => 'exists:products,id',
]);
}
}
Custom Failure Responses
By default a failed Form Request redirects back (browser) or returns a 422 with Laravel's standard JSON shape (API). To match a specific API contract, override failedValidation() and failedAuthorization().
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class ApiFormRequest extends FormRequest
{
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422)
);
}
protected function failedAuthorization(): void
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'You are not authorized to perform this action.',
], 403)
);
}
}
Extend your API requests from ApiFormRequest and every one of them speaks your house JSON format automatically. The default shape, for reference, looks like this:
Default 422 response body
{
"message": "The name field is required. (and 1 more error)",
"errors": {
"name": ["The name field is required."],
"email": ["The email field must be a valid email address."]
}
}
Testing Form Requests
The cleanest way to test a Form Request is through a feature test that hits the real route — it exercises authorization, validation, and the controller together, exactly as production will.
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserManagementTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_create_a_user_with_valid_data(): void
{
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->post('/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertRedirect();
$this->assertDatabaseHas('users', ['email' => 'john@example.com']);
}
public function test_invalid_data_returns_validation_errors(): void
{
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->post('/users', [
'name' => '',
'email' => 'not-an-email',
]);
$response->assertSessionHasErrors(['name', 'email', 'password']);
}
public function test_regular_users_are_forbidden(): void
{
$user = User::factory()->create(); // not an admin
$this->actingAs($user)
->post('/users', ['name' => 'Jane'])
->assertForbidden(); // 403 from authorize()
}
}
💡 Modern testing notes
Laravel 11 uses model factories via User::factory() (the old global factory() helper is gone), and PHPUnit prefers public function test_* method names or the #[Test] attribute over the legacy /** @test */ docblock. Pest is also fully supported and reads even cleaner.
Hands-on Exercise
🏋️ Share Rules Across Create & Update
Objective: Build a base request and two children that share rules, then verify it with a test.
Instructions:
- Create an abstract
BaseProductRequestwith abaseRules()method returning rules forname,price, anddescription. - Add a
prepareForValidation()that generates aslugfromnamewhen absent. - Create
StoreProductRequest:authorize()checkscan('create', Product::class);rules()merges base rules with a globally uniquesku. - Create
UpdateProductRequest:authorize()checkscan('update', ...)on the route product; theskurule ignores the current product's row. - Write a feature test asserting that a valid POST creates the product and an invalid one returns errors.
💡 Hint
For the update's unique rule, use the fluent builder: Rule::unique('products','sku')->ignore($this->route('product')). Remember to add a 'slug' => 'required|string' rule so the merged slug survives into validated().
✅ Solution outline
// BaseProductRequest.php
abstract class BaseProductRequest extends FormRequest
{
protected function baseRules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'description' => 'required|string|min:20',
];
}
protected function prepareForValidation(): void
{
if ($this->filled('name') && ! $this->filled('slug')) {
$this->merge(['slug' => \Illuminate\Support\Str::slug($this->name)]);
}
}
}
// StoreProductRequest.php
class StoreProductRequest extends BaseProductRequest
{
public function authorize(): bool
{
return $this->user()->can('create', \App\Models\Product::class);
}
public function rules(): array
{
return array_merge($this->baseRules(), [
'sku' => 'required|string|max:50|unique:products,sku',
]);
}
}
// UpdateProductRequest.php
class UpdateProductRequest extends BaseProductRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('product'));
}
public function rules(): array
{
return array_merge($this->baseRules(), [
'sku' => [
'required', 'string', 'max:50',
\Illuminate\Validation\Rule::unique('products', 'sku')
->ignore($this->route('product')),
],
]);
}
}
Summary & Quiz
🎉 Key Takeaways
- A Form Request bundles authorization + validation for one entry point, leaving controllers clean.
- The lifecycle runs
prepareForValidation→authorize→ rules →passedValidation→ controller. authorize()is a hard gate: returningfalseyields a 403 before any rule runs.prepareForValidation()reshapes input first; only rule-covered keys survive intovalidated().- Compose shared rules with abstract base classes or traits to avoid duplication.
- Override
failedValidation()to shape API error responses; test requests through feature tests.
🎯 Quick Quiz
Question 1: In the Form Request lifecycle, which hook runs first?
Question 2: Your prepareForValidation() merges a slug key, but $request->validated() doesn't include it. Why?
Question 3: An unauthenticated user hits a route whose Form Request's authorize() returns false. What happens?
📚 Further Reading
- Laravel Docs — Form Request Validation
- Laravel Docs — Authorization (Gates & Policies)
- Laravel Docs — HTTP Tests
🚀 What's Next?
Your forms are secure, validated, and organized. Next we turn outward: building a RESTful API with Laravel — resource routes, JSON responses, API resources, and the conventions that make an API a pleasure to consume.
🎉 Excellent!
You've mastered the gatekeepers of Laravel input. Time to open your app up to the world with an API.