Laravel Form Requests: Validation That Actually Scales
Most Laravel projects start with inline $request->validate() calls in controllers. That works until you have the same rules in three places, conditional validation based on user role, and custom error messages scattered across your codebase.
Form Requests fix this by moving validation—and authorization—into dedicated classes.
Why controllers should not validate
When validation lives in the controller, every endpoint becomes a mix of HTTP concerns, business rules, and database logic. That makes testing harder and encourages copy-paste when two endpoints share similar input shapes.
// ❌ Validation mixed with controller logic
public function store(Request $request)
{
$validated = $request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
'role' => 'required|in:admin,editor,viewer',
]);
$user = User::create($validated);
return new UserResource($user);
}
Extract this into a Form Request and your controller becomes a one-liner.
Creating a Form Request
Generate one with Artisan:
php artisan make:request StoreUserRequest
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', 'in:admin,editor,viewer'],
'team_id' => ['required', 'exists:teams,id'],
];
}
public function messages(): array
{
return [
'email.unique' => 'This email is already registered.',
'role.in' => 'Choose a valid role: admin, editor, or viewer.',
];
}
}
Your controller now reads cleanly:
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
return new UserResource($user);
}
Laravel runs authorize() before rules(). If authorization fails, the user gets a 403 automatically—no extra Gate::authorize() calls needed.
Conditional validation with prepareForValidation
Real-world forms often need to normalize input before rules run. Use prepareForValidation() to merge, trim, or cast values:
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->input('email', ''))),
'phone' => preg_replace('/\D/', '', $this->input('phone', '')),
]);
}
For rules that depend on other fields, use withValidator():
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if ($this->input('role') === 'admin' && ! $this->user()->isSuperAdmin()) {
$validator->errors()->add('role', 'Only super admins can assign the admin role.');
}
});
}
Reusing rules with Rule objects and traits
When UpdateUserRequest shares 80% of rules with StoreUserRequest, extract shared logic:
trait UserValidationRules
{
protected function baseRules(?int $userId = null): array
{
return [
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($userId),
],
'role' => ['required', Rule::enum(UserRole::class)],
];
}
}
class UpdateUserRequest extends FormRequest
{
use UserValidationRules;
public function rules(): array
{
return array_merge($this->baseRules($this->route('user')->id), [
'password' => ['nullable', 'confirmed', Password::defaults()],
]);
}
}
Custom validation rules
For domain-specific checks, create invokable rule classes:
php artisan make:rule ValidNepaliPhone
class ValidNepaliPhone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! preg_match('/^(97|98)\d{8}$/', $value)) {
$fail('The :attribute must be a valid Nepali mobile number.');
}
}
}
Use it like any other rule:
'phone' => ['required', new ValidNepaliPhone()],
Array and nested validation
APIs often accept nested JSON. Laravel handles this well:
public function rules(): array
{
return [
'customer.name' => ['required', 'string', 'max:120'],
'customer.email' => ['required', 'email'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'items.*.price' => ['required', 'numeric', 'min:0'],
];
}
The items.*.field syntax validates every element in the array—a pattern you’ll use constantly in e-commerce and invoicing APIs.
Testing Form Requests
Form Requests are plain PHP classes. Test them without hitting HTTP:
it('requires a unique email on store', function () {
User::factory()->create(['email' => '[email protected]']);
$request = StoreUserRequest::create('/users', 'POST', [
'email' => '[email protected]',
'password' => 'Secret123!',
'password_confirmation' => 'Secret123!',
'role' => 'editor',
'team_id' => 1,
]);
$validator = Validator::make(
$request->all(),
(new StoreUserRequest())->rules()
);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('email'))->toBeTrue();
});
Practical checklist
- One Form Request per controller action when validation differs
- Put authorization in
authorize(), not the controller - Use
Rule::enum()with PHP 8.1+ enums for type-safe roles and statuses - Extract shared rules into traits or dedicated rule builder classes
- Keep custom domain validation in invokable
ValidationRuleclasses - Test validation logic in isolation—it pays off quickly
Form Requests are one of Laravel’s most underused features in junior codebases and one of the highest-ROI refactors in mature ones. Start with your busiest endpoints and work outward.