A Laravel API endpoint answers failed validation with a 302 redirect instead of a 422 JSON body. What decides the response type, and what does the 422 contain?
answer
- expectsJson(), not Content-Type
- first Accept type must be JSON
- shouldRenderJsonWhen in withExceptions
- message: first error plus a count
- errors keyed by dot paths
basics
~20 sThe exception handler renders JSON when the shouldRenderJsonWhen callback says so, or otherwise when $request->expectsJson() is true: a JSON-first Accept header, or an XHR accepting anything. The 422 body is {message, errors}: the first message plus a count, and every field's messages.
solid answer
~40 sA `ValidationException` reaches `convertValidationExceptionToResponse()`. Unless the exception carries its own response, the handler asks `shouldReturnJson()`: the callback registered with `$exceptions->shouldRenderJsonWhen()` if there is one, else `$request->expectsJson()`. That is true when the first `Accept` type contains `/json` or `+json`, or when an `X-Requested-With: XMLHttpRequest` request accepts any type. A JSON `Content-Type` alone does not count, so without a callback a client that omits `Accept` gets a redirect to the previous URL, which is `/` with no referrer. Since skeleton v13.8.0, `bootstrap/app.php` registers `shouldRenderJsonWhen(fn ($r) => $r->is('api/*') || $r->expectsJson())`; older apps lack it. The JSON body is `{"message": ..., "errors": {...}}` with status 422: `message` is the first error plus "(and N more errors)", `errors` maps dot-notation fields to message arrays.
code
bash · 8 lines# 302 to / on an app without the api/* callback: no Accept header
curl -i -X POST https://benefits.test/api/claims \
-H 'Content-Type: application/json' -d '{}'
# 422 JSON body: the client states what it accepts
curl -i -X POST https://benefits.test/api/claims \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' -d '{}'go deeper
Recall that API clients get a 422 JSON body with message and errors, and that they must send Accept: application/json.
Explain expectsJson(): the first Accept type or an any-type XHR, never Content-Type, and what the redirect path does instead.
Diagnose a 302-then-200 integration failure from headers, know the skeleton's shouldRenderJsonWhen default and its version, and fix it centrally rather than per controller.
Treat the 422 body as a public contract: decide whether clients may rely on Laravel's message/errors shape or whether the API needs its own versioned error format.
## The symptom A partner system submits new benefit claims to `POST /api/claims` on an app first created on an early Laravel 13 skeleton. It sends `Content-Type: application/json` but no `Accept` header. When a field fails, the client receives a **302** pointing at the site root; an HTTP library that follows redirects then gets the HTML home page with a **200**, and the integration logs a confusing success. The cause is how Laravel decides between its two validation responses. ## The decision path When a `ValidationException` escapes the controller or form request, `Illuminate\Foundation\Exceptions\Handler` does this: 1. If the exception already carries a `response`, that response is returned as is. 2. Otherwise it calls `shouldReturnJson($request, $e)`. 3. If a callback was registered with `$exceptions->shouldRenderJsonWhen(...)` in `bootstrap/app.php`, its boolean result decides. 4. Without a callback, `$request->expectsJson()` decides. 5. **JSON path** (`invalidJson()`): `response()->json(['message' => ..., 'errors' => ...], $exception->status)`, status 422 by default. 6. **Redirect path** (`invalid()`): redirect to the exception's `redirectTo`, else `url()->previous()`, flashing input and errors to the session. `url()->previous()` uses the `Referer` header, then the session's previous URL, then falls back to `/`, which is why stateless API clients land on the home page. ## What `expectsJson()` actually tests `expectsJson()` is `(ajax() && ! pjax() && acceptsAnyContentType()) || wantsJson()`, and `wantsJson()` looks only at the **first** acceptable type: | Request headers | `expectsJson()` | |---|---| | `Accept: application/json` | true | | `Accept: application/vnd.api+json` | true (`+json`) | | `X-Requested-With: XMLHttpRequest`, no `Accept` | true | | `Content-Type: application/json` only | **false** | | `Accept: text/html, application/json` | false (HTML is first) | The request body's content type plays no part. The fix on the client is simply `Accept: application/json`. ## The Laravel 13 skeleton default The `laravel/laravel` skeleton added a callback in **v13.8.0** (and restored the `expectsJson()` fallback in v13.9.0), so a new app's `bootstrap/app.php` contains: ```php ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*') || $request->expectsJson(), ); }) ``` With it, anything under `api/*` gets JSON regardless of headers. Apps created from an earlier 13.x skeleton, or upgraded from Laravel 12, keep whatever their `bootstrap/app.php` says, often an empty `withExceptions` closure. Adding the same callback is the server-side fix. ## Anatomy of the 422 body ```json { "message": "The household income field is required. (and 2 more errors)", "errors": { "household_income": ["The household income field is required."], "dependents.1.birth_date": ["The dependents.1.birth_date field is required."], "email": ["The email field must be a valid email address."] } } ``` - **`message`** is built by `ValidationException::summarize()`: the first message, then `(and :count more error)` or `(and :count more errors)` passed through the translator, so a locale's JSON translations can translate the suffix. With no messages at all it is "The given data was invalid." - **`errors`** is the validator's `MessageBag::messages()`: every failing field, keyed by its **dot path**, each holding an array because a field can fail several rules. All failing fields are present, not only the first. - **Status** comes from the exception's public `status` property, 422 by default. - **Named error bags play no part**; they exist only in the redirect's session flash. ## Why not return JSON for everything It is tempting to register `shouldRenderJsonWhen(fn () => true)` and be done. That breaks every server-rendered form in the app: a failed Blade form post would receive a raw JSON document instead of being redirected back with its errors and old input. The two response shapes serve two different clients, and the choice has to follow the client: - **Browsers submitting HTML forms** need the redirect, because the page must be re-rendered with `$errors` shared from the session. - **API and script clients** need the 422 body, because they render errors themselves and cannot follow a redirect to a page they never display. - **Mixed apps** usually split on the route prefix (`api/*`) plus `expectsJson()`, exactly the rule the current skeleton ships. ## Takeaways for a senior review - Diagnose "validation returns 302" by inspecting the request's `Accept` and `X-Requested-With` headers before touching the controller. - Prefer a routing-level rule (`shouldRenderJsonWhen`) over try/catch blocks that reformat errors in each controller. - Clients should drive field highlighting from `errors`, and treat `message` as a summary line.
- What happens when the client sends `Accept: text/html, application/json`?`wantsJson()` inspects only the first acceptable type, which is HTML, and the request is not an any-type XHR, so `expectsJson()` is false. Without a `shouldRenderJsonWhen` callback that matches the route, the client gets the redirect.
- Why does the `message` field repeat the first error, and should a client display it?`ValidationException::summarize()` builds it from the first message plus "(and N more errors)" so a client that shows one line still says something useful. Clients should highlight fields from the `errors` object and use `message` only as a fallback banner; showing both duplicates the first error.
- How would you make a Laravel 12-era `bootstrap/app.php` return JSON for every route under `/api`?Register a callback in `withExceptions`: `$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*') || $request->expectsJson())`. It is the same callback the Laravel 13 skeleton ships since v13.8.0, and it applies to all exceptions the handler renders, not only validation failures.
saying these in an interview costs you the question
- Sending Content-Type: application/json is enough to get a JSON validation error.
- Laravel returns 400 Bad Request for validation failures on API routes.
- The errors object lists only the first field that failed.
- The message key is a fixed "The given data was invalid." string whenever errors exist.
- Every Laravel 13 app renders api/* exceptions as JSON, whatever its bootstrap/app.php says.