skip to content

With Inertia and Laravel, how do failed validation errors reach the page component as props without a 422 JSON response?

level: middleimportance: must knowfreq 55%

answer

  1. Inertia never sees a 422
  2. Accept: text/html means a redirect back
  3. errors flashed to the session
  4. share() adds errors via Inertia::always
  5. X-Inertia-Error-Bag nests the default bag

basics

~20 s

Laravel treats the Inertia visit as a normal form post: validation failure redirects back with errors flashed to the session, and on the follow-up request the Inertia middleware shares them as the always-included errors prop, which the client routes to onError.

solid answer

~40 s

An Inertia visit sends `Accept: text/html, application/xhtml+xml`, so Laravel's exception handler does not treat it as a JSON request. A `ValidationException` from `$request->validate()` or a form request therefore becomes a redirect to the previous URL with the messages flashed to the session. The XHR follows that redirect, and on the GET the `HandleInertiaRequests` middleware's default `share()` adds `errors`, wrapped in `Inertia::always()`, built from the session's bags with the first message per field (`$withAllErrors = true` gives arrays). The client checks `page.props.errors`: if it has keys, `onError` fires instead of `onSuccess` and `useForm` fills `form.errors`. Non-GET visits preserve component state, so typed values survive. With two forms sharing field names, an `errorBag` option sends `X-Inertia-Error-Bag` and the adapter nests the messages under that key.

code

php · 20 lines
php
<?php

namespace App\Http\Controllers;

use App\Models\Customer;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function update(Request $request, Customer $customer)
    {
        // On failure: ValidationException -> redirect back, errors flashed.
        $customer->update($request->validate([
            'name' => ['required', 'max:100'],
            'email' => ['required', 'email'],
        ]));

        return to_route('customers.edit', $customer);
    }
}

go deeper

for a junior

Know that failed validation comes back as an errors prop, not a 422, and that you display it next to each input.

for a middle

Walk the round trip: exception, redirect back, session flash, share() with Inertia::always, the client's props.errors check and the onError callback.

for a senior

Diagnose the classic breakages: an overridden share() dropping errors, a JSON Accept header forcing 422s, or two forms fighting over one field name until an error bag scopes them.

for a principal

Explain why the redirect-and-props design keeps validation authoritative on the server, and when a JSON endpoint beside Inertia is the better fit for a widget.

## The problem Inertia avoids In a classic single-page app, a form posts JSON, the server answers `422 Unprocessable Entity` with an errors object, and client code catches that response and copies the messages into local state. **Inertia** deliberately does not work that way. Its page components receive all data as **props**, including validation errors, and a submission behaves like an old-fashioned full-page form post that just happens to run over XHR. An Inertia form therefore never sees a 422. ## Step by step on a customer update 1. The page calls `form.put('/customers/42')`. The request carries `X-Inertia: true`, `X-Requested-With: XMLHttpRequest` and `Accept: text/html, application/xhtml+xml`. 2. The controller validates, for example `$request->validate([...])`, and a rule fails, throwing `ValidationException`. 3. Laravel's exception handler asks whether the request **expects JSON**. Because the `Accept` header lists HTML types rather than `*/*` or JSON, the answer is no, so it takes the redirect branch: back to the previous URL, with the messages flashed to the session under `errors` and the old input flashed alongside. 4. The browser's XHR follows the redirect with a GET to the edit page (the Inertia middleware turns a 302 after PUT, PATCH or DELETE into a 303 so the method becomes GET). 5. That GET hits the controller's `edit` action, which returns `Inertia::render('Customers/Edit', ...)`. The **`HandleInertiaRequests`** middleware has already shared its props for this request. 6. The client receives the page object, swaps props, and inspects `props.errors`. If the object has any key, it fires the `error` event and the visit's **`onError`** callback, not `onSuccess`. 7. `useForm` (or the `<Form>` component) clears its old errors and copies these into `form.errors`. ## What the Laravel adapter shares The middleware base class in `inertia-laravel` defines `share()` as: - `'errors' => Inertia::always($this->resolveValidationErrors($request))` **`Inertia::always`** means the prop is included even on partial reloads that ask for other props only, so errors cannot silently disappear. `resolveValidationErrors()` reads the session's `errors` value (Laravel's `ViewErrorBag`) and converts it: | Session state | `errors` prop | |---|---| | no errors | empty object `{}` | | default bag only | `{ email: 'The email field is required.' }` | | default bag and `X-Inertia-Error-Bag: updateCustomer` header | `{ updateCustomer: { email: '...' } }` | | only named bags | one nested object per bag name | By default each field holds only its **first message** as a string. Setting `protected $withAllErrors = true;` in your `HandleInertiaRequests` subclass sends arrays of every message instead. Because flashed session data lives for one request, the next visit shares an empty `errors` object again and the messages vanish on their own. ## Why typed values survive Router calls for `post`, `put`, `patch` and `delete` default to **`preserveState: true`**, so when the response names the same page component, Inertia updates its props without remounting it. The form helper's state, holding what the user typed, is untouched. That is why Inertia forms need neither Blade's `old()` helper nor any manual repopulation. ## Error bags for two forms on one page A customer page might hold an "update customer" form and an "add contact" form, both with an `email` field. Reading `page.props.errors.email` directly would show a message under both inputs. There are two fixes: - **Use the form helper**: `form.errors` is scoped to the form object that sent the visit, so the other form is unaffected. - **Use an error bag**: pass `errorBag: 'updateCustomer'` in the visit options (or the `errorBag` prop on `<Form>`). The client sends it as the `X-Inertia-Error-Bag` header, the adapter nests the default bag's messages under that key, and the client hands only that nested object to `onError`. Error bags matter mostly when code reads `page.props.errors` directly, for example from a layout or a manual `router.post` call. ## Ways the round trip breaks - **An overridden `share()`** that returns a fresh array instead of merging `parent::share($request)` drops the `errors` prop, so every failed save looks like a success. - **A handler forced to JSON**, for example `shouldRenderJsonWhen(fn () => true)` in `withExceptions`, turns the failure into a 422 JSON response. That response is not an Inertia response, so the client treats it as an HTTP exception and shows its error dialog instead of calling `onError`. - **A redirect to another page** after validation (a custom `redirectTo`) still carries the errors, but only a page that renders the same form can show them next to the inputs. ## What this means in an interview The answer that lands is the round trip: **exception, redirect, session flash, shared prop, client-side check**. Weak answers usually either expect a 422 to catch or assume the controller must pass `errors` to `Inertia::render` itself.

  • Why does the controller never pass errors to Inertia::render?
    The base `Inertia\Middleware::share()` already adds `errors` to every response, built from the session's flashed `ViewErrorBag`. If you override `share()`, merge with `parent::share($request)` or the errors prop disappears and every submission looks successful to the client.
  • How does the client decide a visit failed validation if the status is 200?
    After swapping the page it reads `page.props.errors`. Any key in that object means failure, so it fires the error event and `onError` with the (optionally bag-scoped) errors. An empty object means success, so `onSuccess` runs.
  • When would you reach for an errorBag instead of relying on the form helper?
    When code reads the shared `page.props.errors` directly, such as a manual `router.post` call or a component outside the form, and two forms share field names. The bag nests one form's messages under its own key so the other form's inputs stay clean.

It works like a paper form handed back at a counter: the clerk does not phone you with a list of problems, they return your own form with notes clipped to the wrong fields, and you correct it in place. Inertia's redirect-back puts the notes (the errors prop) on the same page you submitted from.

saying these in an interview costs you the question

  • Catch the 422 response and copy its JSON errors into state
  • The controller must pass errors into Inertia::render itself
  • Errors come back as arrays of every message by default
  • Old input must be restored with old() after the redirect
  • An errorBag is required whenever a page has one form