Laravel Form Requests: Validation That Belongs in Its Own Class

A controller that validates, authorizes, sanitizes input, and then does the actual work stops being readable around the 40-line mark. A validation form request splits that responsibility: validation and authorization live in a dedicated class, the controller receives already-clean data. The result is a thinner controller, a validation request class you can test in isolation, and a single place to look when a rule needs to change.

The lifecycle of a form request follows a strict order: prepareForValidation runs first, then authorize, then the rules from rules() are evaluated, then after() hooks fire, and finally passedValidation transforms the output. Understanding this sequence explains most of the surprising behavior people run into.

This article focuses on the Form Request class itself – its hooks, patterns, and edge cases that come up in production apps. For general validation mechanics see the quick start guide; for individual rule syntax, see the rules reference.

Creating a Form Request

php artisan make:request StoreInvoiceRequest

The file lands in app/Http/Requests. Out of the box it has two methods – authorize() and rules():

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreInvoiceRequest extends FormRequest
{
    public function authorize(): bool
    {
        // generated class returns false by default – change this
        return true;
    }

    public function rules(): array
    {
        return [
            'client_id'       => ['required', 'integer', 'exists:clients,id'],
            'due_date'        => ['required', 'date', 'after:today'],
            'currency'        => ['required', 'string', 'size:3'],
            'line_items'      => ['required', 'array', 'min:1'],
            'line_items.*.description' => ['required', 'string', 'max:255'],
            'line_items.*.amount'      => ['required', 'numeric', 'min:0.01'],
        ];
    }
}

Wire it into a controller by type-hinting the class. Laravel resolves it from the container, runs validation, and only then calls the method body:

use App\Http\Requests\StoreInvoiceRequest;

public function store(StoreInvoiceRequest $request): RedirectResponse
{
    $invoice = Invoice::create($request->safe()->only([
        'client_id', 'due_date', 'currency',
    ]));

    $invoice->lineItems()->createMany($request->validated()['line_items']);

    return to_route('invoices.show', $invoice);
}

Naming convention that keeps things predictable: verb + entity + Request. StoreInvoiceRequest, UpdateProductRequest, DestroySubscriptionRequest. One class per endpoint, one place to find the rules.

The validated() method returns only the fields that passed through rules. $request->safe() returns a ValidatedInput object with only(), except(), and all() methods for finer control over what reaches the model. Never pass $request->all() to create() or update() – that defeats the purpose of the form request by including unvalidated fields.

Authorization

The form request authorize callback runs before any rule is evaluated. Return false and the user gets a 403 – no validation errors, no redirect with input. This is the number one cause of “form request validation not working” posts: the generated class returns false by default, so you get a 403 instead of a 422.

For public endpoints, return true. For anything protected, use gates or policies:

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

Route model binding works here too. If your route is /projects/{project}, access the resolved model directly:

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

If authorization lives entirely in middleware or somewhere else in your stack, remove the method. FormRequest returns true when authorize() is absent.

A more involved example – a multi-tenant SaaS where users can only create invoices for their own organization:

public function authorize(): bool
{
    $client = Client::find($this->input('client_id'));

    // the client must belong to the same org as the authenticated user
    return $client
        && $client->organization_id === $this->user()->organization_id;
}

This check runs before validation, so if client_id is missing or malformed, the find() returns null and authorize() returns false – a 403. For most applications that is acceptable: the user should not reach the endpoint without a valid client selection. If you prefer a validation error instead, move the ownership check into an after() hook and let authorize() handle only role-based access.

One Request for Store and Update

A common question: should store and update share a form request class, or should each have its own? Conditional form request rules make it possible to share a single class when the rules are mostly identical. Both approaches work. Sharing saves boilerplate when the rules are 90% identical; separate classes are cleaner when the forms differ significantly.

When sharing, the usual challenge is the unique rule – on create it must reject duplicates, on update it must ignore the current record. The route method tells you which scenario you are in:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class SaveProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $productId = $this->route('product')?->id;

        return [
            'name'  => ['required', 'string', 'max:255'],
            'sku'   => [
                'required',
                'string',
                'max:40',
                Rule::unique('products', 'sku')->ignore($productId),
            ],
            'price' => ['required', 'numeric', 'min:0'],
            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
        ];
    }
}

When $productId is null (store route), ignore() has no effect and the rule behaves as a plain unique. On update, it skips the current record. More on the unique builder in the unique and exists guide.

An alternative is separate request classes that extend a common base:

abstract class BaseProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function sharedRules(): array
    {
        return [
            'name'   => ['required', 'string', 'max:255'],
            'price'  => ['required', 'numeric', 'min:0'],
            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
        ];
    }
}

class StoreProductRequest extends BaseProductRequest
{
    public function rules(): array
    {
        return array_merge($this->sharedRules(), [
            'sku' => ['required', 'string', 'max:40', 'unique:products,sku'],
        ]);
    }
}

class UpdateProductRequest extends BaseProductRequest
{
    public function rules(): array
    {
        return array_merge($this->sharedRules(), [
            'sku' => [
                'required', 'string', 'max:40',
                Rule::unique('products', 'sku')->ignore($this->route('product')),
            ],
        ]);
    }
}

This pattern avoids conditional branching in rules() and makes each request class self-contained. The trade-off is an extra class per action.

For deeper conditional logic – rules that apply only in certain cases – see conditional validation.

Custom Error Messages and Attribute Names

Form request validation messages can be customized per field/rule pair. Override messages() to replace the default error strings. Override attributes() to change how field names appear in the :attribute placeholder:

public function messages(): array
{
    return [
        'line_items.required'      => 'At least one line item is needed.',
        'line_items.*.amount.min'  => 'Each line must be at least :min.',
        'due_date.after'           => 'The due date cannot be in the past.',
    ];
}

public function attributes(): array
{
    return [
        'client_id'                  => 'client',
        'line_items.*.description'   => 'item description',
        'line_items.*.amount'        => 'item amount',
    ];
}

These messages apply only to this form request validation example. For app-wide customization, edit lang/en/validation.php instead.

The attributes() method is particularly useful with form request validation array fields where raw dot-notation keys produce ugly errors. Without it, a failure on recipients.3.email produces “The recipients.3.email field is required.” – confusing for end users. Map it to “recipient email” and the message becomes human-readable.

One edge case to remember: errors added through the after() hook bypass messages(). They use the raw string you pass to $validator->errors()->add(), so provide the final user-facing text right there.

Sanitizing Input Before Rules Run

The form request prepareForValidation hook is the place to clean up input before rules run. This form request before validation step fires before authorize() and before any rule is evaluated. Use $this->merge() to modify specific keys without touching the rest of the input:

use Illuminate\Support\Str;

protected function prepareForValidation(): void
{
    $this->merge([
        'slug'  => Str::slug($this->slug ?? $this->title),
        'email' => strtolower(trim($this->email)),
    ]);
}

A subtle distinction: merge() adds or overwrites only the keys you specify. replace() wipes the entire input and substitutes it with the array you pass – a completely different operation. Mixing them up silently drops fields.

This method is the right place for setting a form request default value when a field may be absent:

protected function prepareForValidation(): void
{
    $this->mergeIfMissing([
        'locale'   => 'en',
        'timezone' => 'UTC',
        'notify'   => false,
    ]);
}

Now locale, timezone, and notify will always be present when rules run, even if the client did not send them.

A practical use case for before validation processing: phone number normalization. The user might submit +1 (555) 867-5309 or 5558675309 or +15558675309. Strip it down before rules evaluate:

protected function prepareForValidation(): void
{
    if ($this->has('phone')) {
        $this->merge([
            'phone' => preg_replace('/[^0-9+]/', '', $this->phone),
        ]);
    }
}

The rule can then validate a consistent format (digits_between:10,15) without worrying about the input variations. This approach is better than putting the normalization in the controller or a mutator because the cleaned value is what gets validated – no mismatch between what was checked and what was stored.

passedValidation: Transforming Output After Rules Pass

The counterpart to prepareForValidation. It runs only when validation succeeds, so the data is already trusted. Use it to normalize output before the controller sees it:

protected function passedValidation(): void
{
    $this->merge([
        'name'    => Str::title($this->name),
        'website' => Str::start($this->website, 'https://'),
    ]);
}

The phone was already cleaned in prepareForValidation – here the focus is on formatting that only makes sense after validation passes. Capitalizing the name and prefixing the URL are output concerns, not input sanitization.

Use merge() here – it updates only the keys you specify, leaving the rest of the validated input intact. Calling replace() with a partial set of fields would wipe everything else from the request, causing “required” errors downstream or silently dropping data.

Together, prepareForValidation and passedValidation form an input/output pipeline: raw data in, sanitized data before validation, normalized data after. The controller never has to think about trimming, casing, or formatting.

The after() Hook: Cross-Field Validation

Individual rules validate one field at a time. The form request after validation hook covers the rest: a discount percentage that cannot exceed the line item total, a date range where start must precede end, an address that only makes sense with a valid country/postal code pair.

Return an array of callables. Each receives the Validator instance:

use Illuminate\Validation\Validator;

public function after(): array
{
    return [
        function (Validator $validator) {
            $start = $this->input('start_date');
            $end   = $this->input('end_date');

            if ($start && $end && $start >= $end) {
                $validator->errors()->add(
                    'end_date',
                    'End date must come after the start date.'
                );
            }
        },
    ];
}

For anything more involved, extract the logic into an invokable class. This keeps the form request short and makes the validation reusable:

namespace App\Validation;

use Illuminate\Validation\Validator;

class ValidateDiscountCap
{
    public function __invoke(Validator $validator): void
    {
        $data = $validator->validated();

        if (!isset($data['discount_percent'], $data['subtotal'])) {
            return;
        }

        $maxDiscount = $data['subtotal'] * 0.30;
        $requested   = $data['subtotal'] * ($data['discount_percent'] / 100);

        if ($requested > $maxDiscount) {
            $validator->errors()->add(
                'discount_percent',
                'Discount cannot exceed 30% of the subtotal.'
            );
        }
    }
}

Wire it in:

public function after(): array
{
    return [
        new \App\Validation\ValidateDiscountCap,
    ];
}

Accessing validator instance data this way lets you add errors on any field, check previously validated values, and run logic that depends on the combined state of the request. The request validation object is fully populated at this point, so you have access to everything that passed individual rules.

You can also use closures, Rule objects, or inline custom rules for single-field checks. The after() hook is specifically for cases where the validation depends on the relationship between fields. For single-field custom logic, a custom rule class is a better fit.

In older Laravel code you may encounter withValidator(Validator $validator) – it served the same purpose as after() but received the validator directly instead of returning an array of callables. Both still work, but after() is the current approach and composes better when you have multiple checks.

PHP 8 Attributes

Laravel reads several PHP 8 attributes on form request classes. They replace what used to be property overrides.

#[StopOnFirstFailure] – stop validating all attributes as soon as one fails. Useful for forms where a single invalid field means the rest is meaningless:

use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;

#[StopOnFirstFailure]
class ImportRowRequest extends FormRequest
{
    // ...
}

#[RedirectTo('/checkout')] and #[RedirectToRoute('checkout.index')] – control where a failed web request redirects:

use Illuminate\Foundation\Http\Attributes\RedirectToRoute;

#[RedirectToRoute('products.create')]
class StoreProductRequest extends FormRequest
{
    // ...
}

#[ErrorBag('shipping')] – store errors in a named error bag instead of the default one. Relevant when a page contains multiple independent forms:

use Illuminate\Foundation\Http\Attributes\ErrorBag;

#[ErrorBag('shipping')]
class UpdateShippingRequest extends FormRequest
{
    // ...
}

In Blade, read the named bag with $errors->shipping->first('address').

#[FailOnUnknownFields] – strict mode. Any field sent by the client that is not covered by rules() causes a validation error. Prevents unexpected keys from sneaking through:

use Illuminate\Foundation\Http\Attributes\FailOnUnknownFields;

#[FailOnUnknownFields]
class UpdateSettingsRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'theme'    => ['required', 'string', Rule::in(['light', 'dark'])],
            'language' => ['required', 'string', 'size:2'],
        ];
    }
}

This attribute is less well-known than the others but pairs well with API endpoints where you control the payload shape and want to reject anything unexpected.

When using wildcard array rules like items.*, include an explicit rule for the parent key too – not because strict mode requires it (dotted keys from arrays match wildcards correctly), but because the array rule on the parent ensures the input is actually an array:

'items'   => ['required', 'array'],
'items.*' => ['string', 'max:100'],

You can enable strict mode globally with FormRequest::failOnUnknownFields() in a service provider, then selectively disable it per request with #[FailOnUnknownFields(false)].

API Controllers and JSON Responses

When a form request fails on a standard web route, Laravel redirects back with flashed errors. For a form request validation api endpoint, you expect a 422 JSON response instead. Laravel does this automatically when $request->expectsJson() returns true – which happens when the request is an XHR call or sends Accept: application/json.

If neither condition is met, Laravel treats it as a regular browser request and redirects. This is the most common reason form request validation appears “not working” in API projects. The fix is on the client side: set the Accept header or use XHR. In a test:

public function test_store_rejects_invalid_payload(): void
{
    $response = $this->postJson('/api/orders', [
        'customer_id' => null,
    ]);

    $response->assertStatus(422)
        ->assertJsonValidationErrors(['customer_id']);
}

postJson(), putJson(), and friends set Accept: application/json automatically. If you use $this->post() without the Json suffix, set the header manually or you get redirect assertions failing for no obvious reason.

For an API-only project, you can enforce JSON responses globally by adding middleware that sets the Accept header on every request, or by extending the FormRequest class with a custom base that overrides the failedValidation method:

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

abstract class ApiFormRequest extends FormRequest
{
    protected function failedValidation(Validator $validator): void
    {
        // always return JSON, regardless of Accept header
        throw new HttpResponseException(
            response()->json([
                'message' => 'Validation failed.',
                'errors'  => $validator->errors(),
            ], 422)
        );
    }
}

Extend ApiFormRequest instead of FormRequest for all API endpoints, and the redirect problem disappears entirely.

The JSON error format follows a standard shape:

{
    "message": "The customer id field is required. (and 2 more errors)",
    "errors": {
        "customer_id": ["The customer id field is required."],
        "items": ["The items field is required."],
        "items.0.sku": ["The items.0.sku field is required."]
    }
}

Query Parameters and Route Data

You can validate query parameters without any extra work – $this->all() merges both the request body and the query string by default. A form request on a GET route works out of the box:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ListOrdersRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'page'     => ['sometimes', 'integer', 'min:1'],
            'per_page' => ['sometimes', 'integer', 'min:10', 'max:100'],
            'sort'     => ['sometimes', 'string', 'in:created_at,total,status'],
            'dir'      => ['sometimes', 'string', 'in:asc,desc'],
            'status'   => ['sometimes', 'string', 'in:pending,paid,shipped'],
        ];
    }
}

Including Route Parameters via validationData()

If a body parameter and a query parameter share the same key, the body value wins. Override validationData() when you need to validate request parameters from other sources – most commonly route segments. This is handy for endpoints like PUT /teams/{team}/members/{user} where you want to validate that {user} belongs to {team}:

public function validationData(): array
{
    return array_merge($this->all(), [
        'team_id' => $this->route('team')?->id,
        'user_id' => $this->route('user')?->id,
    ]);
}

public function rules(): array
{
    return [
        'team_id' => ['required', 'integer'],
        'user_id' => ['required', 'integer', 'exists:team_members,user_id'],
        'role'    => ['required', 'string', 'in:admin,member,viewer'],
    ];
}

Type-hint the request on a GET route and the query string gets validated before the controller runs:

Route::get('/orders', [OrderController::class, 'index']);

public function index(ListOrdersRequest $request): JsonResponse
{
    $orders = Order::query()
        ->when($request->validated('status'), fn ($q, $s) => $q->where('status', $s))
        ->orderBy(
            $request->validated('sort', 'created_at'),
            $request->validated('dir', 'desc')
        )
        ->paginate($request->validated('per_page', 25));

    return response()->json($orders);
}

Arrays, JSON, and Enums

Form request validation array and JSON payloads use the same dot-notation wildcards as inline validation. To validate request JSON bodies with nested objects, define the rules using dot notation:

class StoreCampaignRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name'              => ['required', 'string', 'max:120'],
            'channels'          => ['required', 'array', 'min:1'],
            'channels.*'        => ['string', 'in:email,sms,push'],
            'schedule'          => ['required', 'array'],
            'schedule.start_at' => ['required', 'date', 'after:now'],
            'schedule.end_at'   => ['nullable', 'date', 'after:schedule.start_at'],
            'recipients'        => ['required', 'array', 'min:1', 'max:10000'],
            'recipients.*.email' => ['required', 'email:rfc'],
            'recipients.*.tags'  => ['nullable', 'array'],
            'recipients.*.tags.*' => ['string', 'max:50'],
        ];
    }
}

For deeper coverage of array and JSON patterns, see array and JSON validation.

Form request validation enum support works through Rule::in or the dedicated Enum rule:

use App\Enums\OrderStatus;
use Illuminate\Validation\Rules\Enum;

public function rules(): array
{
    return [
        'status' => ['required', new Enum(OrderStatus::class)],
    ];
}

The validation rejects any value that does not match a case in the enum – no manual in:pending,paid,shipped list that falls out of sync when you add a case. This matters in projects where enum cases grow over time – with Rule::in you maintain a single source of truth.

For form request validation json payloads, make sure the client sends Content-Type: application/json. Without it, Laravel will not parse the body as JSON and all fields will appear empty. This is separate from the Accept header issue – Content-Type controls parsing, Accept controls the response format.

Dependency Injection in rules()

The rules() method supports type-hinted dependencies resolved through the service container. This is useful when rules depend on configuration or external services:

use App\Services\PricingService;

public function rules(PricingService $pricing): array
{
    return [
        'amount' => [
            'required',
            'numeric',
            'min:' . $pricing->minimumOrderAmount($this->input('currency', 'USD')),
        ],
        'currency' => ['required', 'string', 'in:' . implode(',', $pricing->supportedCurrencies())],
    ];
}

The container injects PricingService automatically. Avoid heavy operations here though – rules() runs on every request, so database queries or API calls in this method add latency to every form submission.

Testing Form Requests

Form requests are easy to test through HTTP tests. Send bad data in, assert a 422 with the right error keys. Send good data in, assert success:

class StoreInvoiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_requires_at_least_one_line_item(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/invoices', [
            'client_id'  => Client::factory()->create()->id,
            'due_date'   => now()->addDays(30)->toDateString(),
            'currency'   => 'USD',
            'line_items' => [],
        ]);

        $response->assertStatus(422)
            ->assertJsonValidationErrors(['line_items']);
    }

    public function test_rejects_past_due_date(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/invoices', [
            'client_id'  => Client::factory()->create()->id,
            'due_date'   => '2020-01-01',
            'currency'   => 'USD',
            'line_items' => [
                ['description' => 'Consulting', 'amount' => 150.00],
            ],
        ]);

        $response->assertJsonValidationErrors(['due_date']);
    }

    public function test_unauthorized_user_gets_403(): void
    {
        // user without the create-invoices permission
        $user = User::factory()->create(['role' => 'viewer']);

        $response = $this->actingAs($user)->postJson('/api/invoices', [
            'client_id' => 1,
        ]);

        $response->assertForbidden();
    }
}

Test the authorize() method separately from the rules. A 403 means authorization failed; a 422 means validation failed. If you get a 403 when you expected a 422, check authorize() first – that is almost always the issue.

Full Recipe: E-Commerce Product CRUD

Putting the pieces together. A product form request that handles both create and update, validates nested variant data, sanitizes input, and runs a cross-field check.

namespace App\Http\Requests;

use App\Enums\ProductStatus;
use App\Validation\ValidateVariantPricing;
use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Validator;

#[StopOnFirstFailure]
class SaveProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        $product = $this->route('product');

        // store: check create permission; update: check update on the model
        return null === $product
            ? $this->user()->can('create', Product::class)
            : $this->user()->can('update', $product);
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->slug ?? $this->title),
        ]);

        $this->mergeIfMissing([
            'track_inventory' => false,
        ]);
    }

    public function rules(): array
    {
        $productId = $this->route('product')?->id;

        return [
            'title'  => ['required', 'string', 'max:200'],
            'slug'   => [
                'required', 'string', 'max:200',
                Rule::unique('products', 'slug')->ignore($productId),
            ],
            'status' => ['required', new Enum(ProductStatus::class)],
            'price'  => ['required', 'numeric', 'min:0.01', 'max:999999.99'],
            'compare_at_price' => ['nullable', 'numeric', 'gt:price'],
            'track_inventory'  => ['boolean'],
            'variants'         => ['nullable', 'array', 'max:50'],
            'variants.*.sku'   => ['required_with:variants', 'string', 'max:40'],
            'variants.*.price' => ['required_with:variants', 'numeric', 'min:0.01'],
            'variants.*.stock' => ['required_if:track_inventory,true', 'integer', 'min:0'],
        ];
    }

    public function after(): array
    {
        return [
            new ValidateVariantPricing,
        ];
    }

    public function messages(): array
    {
        return [
            'compare_at_price.gt' => 'Compare-at price must be higher than the selling price.',
            'variants.*.stock.required_if' => 'Stock is required when inventory tracking is on.',
        ];
    }

    protected function passedValidation(): void
    {
        $this->merge([
            'title' => Str::title($this->title),
        ]);
    }
}

The controller stays short:

public function store(SaveProductRequest $request): RedirectResponse
{
    $product = Product::create($request->safe()->except(['variants']));

    if ($variants = $request->validated('variants')) {
        $product->variants()->createMany($variants);
    }

    return to_route('products.show', $product);
}

public function update(SaveProductRequest $request, Product $product): RedirectResponse
{
    DB::transaction(function () use ($request, $product) {
        $product->update($request->safe()->except(['variants']));
        $product->variants()->delete();

        if ($variants = $request->validated('variants')) {
            $product->variants()->createMany($variants);
        }
    });

    return to_route('products.show', $product);
}

Common Mistakes

403 instead of validation errors. The generated authorize() returns false – see the authorization section above. If you get a 403 where you expected a 422, check this method first.

API returns redirect instead of 422. Laravel uses $request->expectsJson() to decide the response format – it checks for XHR requests and the Accept: application/json header. Without either, a failed form request validation redirects instead of returning JSON. In tests, use postJson() / putJson() instead of post() / put(). In production, ensure your frontend framework or HTTP client sets the header.

prepareForValidation replaces all input by accident. $this->replace([...]) wipes everything and substitutes it with the array you pass. Use $this->merge([...]) when you only want to change specific keys. The symptom is fields silently disappearing from the request – rules that should match suddenly fail with “required” errors because the field no longer exists.

after() errors ignore messages() customization. Strings passed to $validator->errors()->add() go straight to the error bag verbatim. The messages() and attributes() overrides have no effect on them – write the final user-facing text directly in the add() call.

Query string “not validated”. validationData() defaults to $this->all(), which already includes query string parameters. If the same key appears in both body and query, the body value wins. This is rarely a problem, but if you need different precedence or want to include route parameters, override validationData().

Old input lost with file uploads. Files are not flashed to the session – only text fields are. After a failed form request validation on a form with file inputs, the file selection resets. Handle this on the frontend by showing previously uploaded file names from the session or by uploading files separately before form submission. See file validation for upload-specific patterns.

Strict mode and array rules. #[FailOnUnknownFields] rejects keys not covered by rules(). When you use items.* syntax, the wildcard matches dotted keys correctly, but always add 'items' => ['array'] for the parent – not for strict mode, but so the array rule rejects non-array input.

Rules not running for optional fields. A field that is sometimes present in the form but not always needs the sometimes rule. Without it, the rule applies even when the field is absent, which can cause unexpected failures. Combine with nullable when the field can be sent as an empty value:

'nickname' => ['sometimes', 'nullable', 'string', 'max:50'],

Shared form request with different required fields. When store requires a field but update does not (or vice versa), a shared request class gets messy fast. If you find yourself writing $this->isMethod('POST') ? 'required' : 'sometimes' for multiple fields, split into two classes with a shared base – the code will be easier to follow.

For custom rule objects and closures inside form requests, see custom validation rules. For date-specific rules like after, before, and timezone handling, see date validation. For file upload rules, see file validation.