Form Requests
A form request is a Request subclass that authorizes and validates itself before your controller method is entered.
php artisan make:request StorePostRequestA form request is a class extending Illuminate\Foundation\Http\FormRequest that carries the rules for one endpoint. You type-hint it in the controller in place of Request, and by the time the method body runs the input has been authorized and validated.
Generating one
php artisan make:request StorePostRequest writes app/Http/Requests/StorePostRequest.php with two methods: authorize(): bool and rules(): array. In Laravel 13 the generated authorize() returns false.
What is doing the work
FormRequestServiceProvider registers a container callback: afterResolving(ValidatesWhenResolved::class) calls validateResolved() on whatever was resolved. The router resolves a controller method's parameters through the container, so the type-hint is the whole trigger. validateResolved() runs three steps in this order:
prepareForValidation(), a hook for adjusting the input before anything looks at it.passesAuthorization(). A falsy return callsfailedAuthorization(), which throwsAuthorizationExceptionand becomes a 403.- the validator built from
rules(). Failure callsfailedValidation(), which throws the sameValidationExceptionas$request->validate(), with the same redirect-or-422 outcome.
Authorization runs first, so an unauthorized request gets a 403 and never learns whether its data was valid.
In the controller
The class extends Request, so every accessor still works. validated() returns the fields that had rules and takes a key and default, like validated('tag_ids', []). safe() returns the same data as a ValidatedInput object with only() and except() on it. Those two methods are why the controller has no validation code left in it.
Example
<?php
// app/Http/Requests/StorePostRequest.php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StorePostRequest extends FormRequest
{
/**
* Runs BEFORE the rules. A false return is a 403, not a 422.
* $this->user() is the authenticated user for the request's guard.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Keys without a rule here never appear in validated().
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:120'],
'slug' => ['required', 'string', 'max:140'],
'body' => ['required', 'string', 'min:20'],
'tag_ids' => ['array'],
'tag_ids.*' => ['integer'],
];
}
/**
* Runs before authorize() and before the rules. merge() writes into
* the request's own input, so 'slug' is there when the rules run.
*/
protected function prepareForValidation(): void
{
$this->merge([
'slug' => Str::slug((string) $this->input('title')),
]);
}
}When to use it
- Move a long rules array out of a controller that is getting hard to read.
- Reuse one set of rules across a web form and an API endpoint.
- Reject a request for someone else's record with a 403 before validating it.
- Normalise input, such as trimming a phone number or building a slug, before the rules see it.
More examples
The controller that uses it
This is what "the controller stays clean" means in practice: no rules, no error branch, no $request->all(). safe()->except() and validated() both read the validated data, so a field nobody wrote a rule for cannot reach Post::create().
<?php
// app/Http/Controllers/PostController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
class PostController extends Controller
{
// The type-hint is the mechanism. Resolving StorePostRequest from
// the container runs authorize() and then the rules, so by the first
// line of this body both have passed.
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create($request->safe()->except(['tag_ids']));
// validated() takes a key and a default: data_get() over the
// validated array, so a missing optional key is not a notice.
$post->tags()->sync($request->validated('tag_ids', []));
return redirect()->route('posts.show', $post);
}
}Class attributes and the properties they replace
Use these when the default behaviour is wrong for the endpoint: $redirect sends a failure somewhere other than the previous URL, $errorBag separates two forms rendered on one page, and $stopOnFirstFailure stops after the first failing rule set instead of collecting every message. Recognising both forms matters, because older code uses the properties.
<?php
// app/Http/Requests/StorePostRequest.php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\Attributes\ErrorBag;
use Illuminate\Foundation\Http\Attributes\RedirectTo;
use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;
use Illuminate\Foundation\Http\FormRequest;
// Laravel 13 reads these in configureFromAttributes(), which runs
// before the validator is built.
#[RedirectTo('/posts/create')]
#[ErrorBag('createPost')]
#[StopOnFirstFailure]
class StorePostRequest extends FormRequest
{
// The longhand: the same three settings as protected properties.
// They are the same thing; the attributes set these values.
//
// protected $redirect = '/posts/create';
// protected $errorBag = 'createPost';
// protected $stopOnFirstFailure = true;
public function authorize(): bool
{
return true;
}
/** @return array<string, array<mixed>> */
public function rules(): array
{
return ['title' => ['required', 'string', 'max:120']];
}
}Authorizing against the route's model
The ownership check for an update endpoint. Because authorize() runs before the rules, a request for someone else's post gets a 403 with no hint about which fields were malformed. route() also gives rules() access to the record being updated, which is what the unique rule needs in order to ignore the row it is editing.
<?php
// app/Http/Requests/UpdatePostRequest.php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
// $this->route('post') is the {post} segment AFTER route model
// binding resolved it: the same Post instance the controller is
// handed, not the raw id from the URL.
/** @var Post|null $post */
$post = $this->route('post');
return $post !== null
&& $this->user()?->getKey() === $post->user_id;
}
/** @return array<string, array<mixed>> */
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:120'],
'body' => ['sometimes', 'required', 'string', 'min:20'],
];
}
}
Discussion