A Laravel API client posts JSON without an Accept header and gets a 302 redirect instead of JSON errors; what does expectsJson() check, and why?
answer
- Content-Type is not Accept
- isJson() versus wantsJson()
- X-Requested-With XMLHttpRequest
- first acceptable type must be JSON
- Accept: application/json fixes it
basics
~20 sexpectsJson() checks the response the client wants, not the body it sent: an AJAX request accepting anything, or JSON as the first Accept type. A JSON body with Accept: / fails both, so errors render as HTML redirects.
solid answer
~40 s`isJson()` reads the `Content-Type` and only decides how Laravel parses the body. `expectsJson()` decides the response format: it returns `(ajax() && ! pjax() && acceptsAnyContentType()) || wantsJson()`. `ajax()` needs `X-Requested-With: XMLHttpRequest`, and `wantsJson()` needs the **first** acceptable type in `Accept` to contain `/json` or `+json`. A script sending a JSON body with no `Accept` header or `*/*` and no `X-Requested-With` fails both, so the exception handler renders HTML: a failed validation redirects back with a 302, and an unauthenticated request redirects to the login route. Fix it by having clients send `Accept: application/json`, or configure the handler in `bootstrap/app.php` to render JSON for `api/*` routes.
go deeper
Know that expectsJson() tells Laravel whether the caller wants a JSON response, and that clients signal it with the Accept header.
Separate isJson() from expectsJson(), and name its two branches: an AJAX header with a wildcard Accept, or JSON as the first Accept type.
Diagnose 302s and login redirects on API calls from headers alone, and pin the behaviour with a handler rule or middleware plus a test.
Decide whether API routes should honour Accept or force JSON, weighing strict HTTP semantics against friendly errors for careless clients.
## Two headers, two questions An HTTP request carries two headers that are easy to confuse: - **`Content-Type`** describes the body the client **sent**. Laravel's `Illuminate\Http\Request::isJson()` checks it for `/json` or `+json`, and when it matches, `input()` reads the decoded JSON payload instead of the form body. - **`Accept`** describes the response the client **wants**. Laravel's `expectsJson()`, `wantsJson()` and `acceptsJson()` read it. A client can send perfectly valid JSON and still, as far as Laravel is concerned, ask for HTML. That mismatch is the root of the "my API returns a redirect to the home page" bug that senior interviews use as a diagnosis scenario. ## What expectsJson() evaluates The source in `InteractsWithContentTypes` is short: ```php return ($this->ajax() && ! $this->pjax() && $this->acceptsAnyContentType()) || $this->wantsJson(); ``` | Helper | True when | |---|---| | `ajax()` | `X-Requested-With: XMLHttpRequest` is present | | `pjax()` | an `X-PJAX` header is present | | `acceptsAnyContentType()` | no `Accept` header, or the first type is `*/*` or `*` | | `wantsJson()` | the **first** acceptable type contains `/json` or `+json` | | `acceptsJson()` | JSON is acceptable anywhere in the list (not used by `expectsJson()`) | "First" means first after Symfony sorts the `Accept` list by quality, so `Accept: text/html, application/json` is not `wantsJson()`, while `Accept: application/json, text/html` is. ## Walking through the failing request A job-board partner integration posts a new listing from a server-side script: ``` POST /api/jobs Content-Type: application/json Accept: */* ``` 1. `isJson()` is true, so the payload is parsed and `input('title')` works. 2. `ajax()` is false: server-side HTTP clients do not send `X-Requested-With` unless told to. 3. `wantsJson()` is false: the first acceptable type is `*/*`. 4. `expectsJson()` is therefore false. 5. Validation fails and throws a `ValidationException`. The exception handler asks `$request->expectsJson()`, gets false, and renders the HTML flow: a redirect back to the previous URL with input and errors flashed. The client sees a 302 to a page it never visited. 6. The same logic turns an `AuthenticationException` into a redirect to the route named `login` instead of a 401 JSON response. A browser SPA using a library that adds `X-Requested-With` often works "by accident", which is why the bug appears only when a second, headless client arrives. ## Fixing it - **Client side:** send `Accept: application/json`. This is the documented contract and makes every JSON-aware branch in the framework behave. - **Server side, per route group:** in `bootstrap/app.php`, configure the exception handler's JSON-rendering decision with `$exceptions->shouldRenderJsonWhen(fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson())`. - **Server side, in middleware:** some teams add a small middleware to API routes that sets `$request->headers->set('Accept', 'application/json')` before the rest of the stack runs. Whichever you choose, write a feature test that posts without an `Accept` header, so the behaviour is pinned. ## Using expectsJson() in your own code Controllers and middleware that serve both a Blade page and an API can branch on it: - return `response()->json(...)` when `$request->expectsJson()`, a view otherwise; - in custom middleware, abort with a JSON body for API callers and redirect browsers. Prefer `expectsJson()` over checking `isJson()` for this decision: the body format says nothing about what the caller can read. `wantsMarkdown()` and `acceptsMarkdown()` exist too, for clients that ask for `text/markdown`. ## Diagnosis checklist When an API call unexpectedly returns a 302 or an HTML page, check in this order: 1. **The raw request headers**, as the server received them: `Accept`, `Content-Type` and `X-Requested-With`. 2. **The response's `Location` header**: a redirect to the previous URL points at a validation failure; one to the login route points at authentication. 3. **The route group**: an `api` route answering with a redirect back is itself the sign that `expectsJson()` returned false, since a stateless client can do nothing useful with that redirect. 4. **Any custom JSON-rendering rule** in `bootstrap/app.php`, which may cover some paths but not others.
- Why does the same endpoint return JSON errors to the SPA but a redirect to a curl script?Many browser HTTP libraries add `X-Requested-With: XMLHttpRequest` and send `Accept: application/json, text/plain, */*`, so `wantsJson()` or the `ajax()` branch makes `expectsJson()` true. A curl script sends `Accept: */*` and no `X-Requested-With`, so `expectsJson()` is false and the handler renders the HTML redirect.
- An API-only Laravel app with no login route gets a 500 'Route [login] not defined.' on unauthenticated calls. Why?`withMiddleware()` registers `redirectGuestsTo(fn () => route('login'))` by default. When `expectsJson()` is false, the handler redirects the guest to that URL instead of returning a 401 JSON response, and `route('login')` throws `RouteNotFoundException` because no route has that name. Sending `Accept: application/json` avoids the redirect entirely.
- What is the difference between $request->wantsJson() and $request->acceptsJson()?`wantsJson()` is true only when the first, highest-preference type in `Accept` is JSON. `acceptsJson()` is true when JSON is acceptable anywhere in the list, including through `*/*`. `expectsJson()` builds on `wantsJson()`, so a client that merely tolerates JSON does not trigger JSON rendering.
saying these in an interview costs you the question
- Sending Content-Type: application/json makes Laravel reply with JSON
- expectsJson() is true whenever JSON appears anywhere in Accept
- Every route in routes/api.php always returns JSON errors
- The 302 means the route is missing from the api group