How does Laravel 13 decide whether an exception becomes a JSON or an HTML response, and what does shouldRenderJsonWhen change?
answer
- $request->expectsJson() by default
- first Accept type contains json
- skeleton adds is('api/*')
- production body: message or Server Error
- debug adds exception, file, line, trace
basics
~20 sBy default the handler renders JSON when $request->expectsJson() is true: the first Accept type asks for JSON, or an AJAX request accepts anything. shouldRenderJsonWhen() replaces that test; the Laravel 13 skeleton uses api/* paths or expectsJson().
solid answer
~40 sThe handler asks one question per exception: should this request get JSON? Without configuration the answer is `$request->expectsJson()`, which is true when the first `Accept` type contains `/json` or `+json`, or for an AJAX request that accepts any type. `$exceptions->shouldRenderJsonWhen(fn (Request $request, Throwable $e) => ...)` replaces that test. The Laravel 13 skeleton (since v13.9) ships `fn (Request $request) => $request->is('api/*') || $request->expectsJson()`, so API clients that send no `Accept` header still get JSON. The JSON body uses the `HttpException`'s status and headers, or 500. With `APP_DEBUG=false` it is only `{"message": ...}`: the HTTP exception's message, or `Server Error` for anything else. With debug on it adds `exception`, `file`, `line` and `trace`. The same decision also picks 401 JSON over the login redirect.
code
php · 17 lines<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
health: '/up',
)
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request, Throwable $e) => $request->is('api/*', 'partners/*') || $request->expectsJson(),
);
})->create();go deeper
Know that API clients get JSON errors when they send Accept: application/json.
Explain expectsJson's rules, what shouldRenderJsonWhen replaces, and the production versus debug JSON body.
Make API error formats independent of client headers, and keep client-facing messages in HTTP exceptions only.
Decide one error contract per surface and enforce it at the handler rather than in each controller.
## One decision, many consequences Laravel's exception handler serves browsers and API clients from the same code. For each exception it answers a single question, **"should this request get JSON?"**, and the answer changes several outputs: - the generic error response: a JSON body instead of an `errors/{status}` Blade view; - unauthenticated requests: a `401` JSON body instead of a redirect to the login page; - failed validation: a JSON error body instead of a redirect back with errors. ## The default test: expectsJson() Without configuration the handler calls `$request->expectsJson()`, which is true when either: 1. the request is AJAX (`X-Requested-With: XMLHttpRequest`), is not a PJAX request, and accepts any content type (no `Accept` header, or `*/*` first); or 2. `wantsJson()` is true: the **first** type in the `Accept` header contains `/json` or `+json`. Some consequences worth knowing: - A request whose body is JSON (`Content-Type: application/json`) but whose `Accept` asks for HTML gets HTML: the decision reads `Accept`, not `Content-Type`. - A plain `curl` or server-to-server client that sends no `Accept` header, and is not AJAX, gets an **HTML** error page by default. - `Accept: text/html, application/json` gets HTML, because only the first type counts. ## Changing it with shouldRenderJsonWhen() `$exceptions->shouldRenderJsonWhen(callable $callback)` replaces the test. The callback receives the request and the exception and returns a boolean. Since skeleton v13.9 a new Laravel 13 app ships: ```php $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*') || $request->expectsJson(), ); ``` So anything under `api/` gets JSON even without an `Accept` header, and everything else keeps the header-based behaviour. Skeleton v13.8.0 added the `api/*` rule and v13.9.0 restored the `expectsJson()` fallback beside it. An app whose `bootstrap/app.php` predates those releases has no such callback unless someone added one, and uses `expectsJson()` alone. For an airline with a web booking site and a public flight-status API, a typical rule adds partner paths: ```php fn (Request $request, Throwable $e) => $request->is('api/*', 'partners/*') || $request->expectsJson() ``` Laravel 13.6 also added `->prefersJsonResponses()` on the application builder. It prepends a global middleware that rewrites a missing or broad `Accept` header (`*/*`, `application/*`) to `application/json` for every request, which suits API-only applications. ## The unauthenticated case The same decision shapes the response to an `AuthenticationException`, thrown when a guest reaches a route protected by the `auth` middleware: - **JSON:** a `401` with `{"message": "Unauthenticated."}`, the exception's default message. - **HTML:** a redirect to the login URL the exception carries; by default Laravel builds it from the route named `login`. - **HTML with no login URL:** an empty `401` response. This is why an API client that forgets its `Accept` header, on an app without the skeleton's `api/*` rule, is sent a redirect to the login page instead of a `401`, or an error if no route named `login` exists: it was treated as a browser. The skeleton rule, or `prefersJsonResponses()`, fixes that for API paths. ## What the JSON body contains For the generic path the handler uses the exception's status code and headers if it is an HTTP exception, otherwise 500. The body depends on the `debug` setting: | `APP_DEBUG` | HTTP exception, e.g. `abort(404, 'Flight not found.')` | Any other exception | |---|---|---| | `false` | `{"message": "Flight not found."}` | `{"message": "Server Error"}` | | `true` | `message`, `exception`, `file`, `line`, `trace` | the same five keys | Two points follow. HTTP exception messages are shown to clients even in production, so they must be written for clients. And the production body for server faults is deliberately bare; if your API needs a richer shape, such as an error code, write a render callback or a `respond()` hook rather than enabling debug. ## Checking it in practice - Send the same failing request with and without `Accept: application/json` and compare. - Test API routes with a client that sends no `Accept` header at all, the case the skeleton callback exists for. - Keep `APP_DEBUG=false` in production; the debug JSON includes file paths and a stack trace. ## Common mistakes - Believing a JSON request body makes the error JSON. - Assuming every Laravel 13 app has the `api/*` rule; check `bootstrap/app.php`. - Replacing the skeleton callback with a path check alone, so web pages called with `Accept: application/json` start getting HTML again. - Expecting `Server Error` for `abort(404, ...)`; HTTP exceptions keep their own message.
- A client posts JSON but sends Accept: text/html. Which error format does it get by default?HTML. `expectsJson()` reads the `Accept` header, and only its first type, not `Content-Type`. Add the path to `shouldRenderJsonWhen` or have the client send `Accept: application/json`.
- What does prefersJsonResponses() do differently from shouldRenderJsonWhen()?`prefersJsonResponses()` (Laravel 13.6+) is on the application builder and prepends a middleware that rewrites a missing or broad `Accept` header to `application/json` for every request, so every JSON-aware feature sees JSON. `shouldRenderJsonWhen()` only changes the exception handler's decision.
- Why does abort(404, 'Flight not found.') show its message in production JSON while a RuntimeException shows 'Server Error'?The handler treats HTTP exception messages as intended for clients and prints them whatever the debug setting. For any other exception it prints the generic 'Server Error' unless `APP_DEBUG` is on, to avoid leaking internals.
saying these in an interview costs you the question
- Sending a JSON request body is enough to get JSON errors
- Every Laravel 13 app returns JSON for api/* routes whatever its skeleton
- Production JSON errors always include the exception class
- abort(404, 'message') returns 'Server Error' in production JSON
- Accept: text/html, application/json gets a JSON error