Your Laravel API sends CORS headers on normal responses, yet the browser reports CORS errors only on some failed requests — what causes that?
answer
- handled exceptions still get headers
- fatal error rendered at shutdown
- web server rejects before PHP
- middleware ahead of HandleCors
- two Access-Control-Allow-Origin headers
basics
~20 sThose failing responses never pass through HandleCors: PHP fatal errors rendered at shutdown, rejections by the web server or proxy before PHP runs, and failures in global middleware ahead of HandleCors. Exceptions the pipeline handles still get headers.
solid answer
~50 sAn exception thrown in a controller or route middleware — `abort(500)`, a `ValidationException`, an `AuthenticationException` — is rendered inside the middleware pipeline, so the response flows back through `HandleCors` and carries its headers; the framework's tests assert this for a 500 and a validation failure. The broken responses are the ones `HandleCors` never wraps. A PHP fatal error, such as an exhausted memory limit or an execution timeout, is rendered by Laravel's shutdown handler, which sends the response directly. A body the web server or proxy rejects, or a gateway timeout, never reaches PHP. Middleware that runs ahead of `HandleCors`, like `ValidatePathEncoding` or anything added with `prepend()`, fails outside it. And a proxy that adds its own `Access-Control-Allow-Origin` to error pages creates a duplicate header. Each time, the browser shows a CORS error that hides the real status.
code
bash · 4 linescurl -i -X POST https://api.tallyroom.example/api/trial \
-H 'Origin: https://www.tallyroom.example' \
-H 'Accept: application/json' \
-F '[email protected]' | grep -i -E '^(HTTP|access-control)'go deeper
Remember that a CORS error can hide a server failure, so check the real status and the logs before touching config/cors.php.
Explain why exceptions rendered inside the pipeline still pass back through HandleCors, while fatal errors and web-server rejections do not.
Diagnose intermittent CORS failures from headers and logs, align web-server limits with PHP's, and keep a single layer responsible for CORS headers.
Decide whether CORS belongs at the edge proxy or in the application, and make sure error paths from each layer stay readable to the front end.
## Which failures still carry CORS headers Laravel runs a request through an **onion of middleware**: the global stack first, then the route's middleware, then the controller. When something inside that onion throws, the routing pipeline catches the exception at that layer. It asks the exception handler to report and render it, and returns the rendered response to the layer outside. So the error response travels back out through every outer middleware — including `HandleCors`, which then adds its `Access-Control-*` headers exactly as it would for a success. The framework's own `HandleCorsTest` asserts this: a route calling `abort(500)`, and one failing validation, both come back with `Access-Control-Allow-Origin` set. The same holds for a `ModelNotFoundException` rendered as a 404, and for an `AuthenticationException` from `auth:sanctum` rendered as a JSON 401. **So an ordinary exception is almost never the cause.** Look for responses that `HandleCors` never wrapped. ## Which failures bypass HandleCors | Failure | Where the response is produced | CORS headers? | |---|---|---| | Controller or route-middleware exception | Exception handler, inside the pipeline | Yes | | PHP fatal error (memory limit, execution timeout) | Laravel's shutdown handler, which sends the rendered response directly | No | | Body too large for the web server, or a gateway timeout | The web server or reverse proxy, before PHP runs | No | | Exception in a global middleware ahead of `HandleCors` (for example `ValidatePathEncoding`'s `MalformedUrlException`) | Outside `HandleCors` | No | | A middleware added with `$middleware->prepend()` that returns its own response | Ahead of `HandleCors` | No | | Proxy adds its own `Access-Control-Allow-Origin` on error pages | Both layers | Duplicated, and the browser rejects it | The fatal-error row surprises people most. `HandleExceptions` registers a shutdown function. When PHP dies with a fatal error, that function renders the error through the exception handler and calls `send()` on the response itself. The middleware stack has already been abandoned at that point, so `HandleCors`'s after-logic never runs. ## Diagnosing it 1. **Reproduce without the browser.** Send the failing request with an `Origin` header and `-i`, and compare its headers with a successful call's. A missing `Access-Control-Allow-Origin` on a 5xx points at one of the bypass rows. 2. **Check the Laravel log.** A `FatalError` entry, which Laravel builds from `error_get_last()` at shutdown, confirms the fatal-error path. 3. **Check the web server's error log** for body-size or upstream-timeout rejections that never reached PHP. 4. **Count the headers.** Two `Access-Control-Allow-Origin` lines mean two layers are both answering CORS. 5. **Check the global order** if a custom middleware was prepended: anything before `HandleCors` fails without headers. ## Fixing the causes - **Heavy work**: move it to a queued job, or raise the relevant limit, so the request stops dying mid-flight. - **Upload size**: align the web server's body limit with `post_max_size`. `ValidatePostSize` runs inside `HandleCors`, so an oversized body that PHP sees gets a proper `413` with CORS headers. - **One owner for CORS**: let either the proxy or `HandleCors` send the headers, never both. - **Middleware order**: append custom global middleware, rather than prepending it, unless it genuinely must run before CORS handling. - **After editing `config/cors.php` on a server with cached configuration**, rebuild the cache. Otherwise the old values stay in force and every response looks wrong, not just failures. ## Why it matters A CORS error hides the real failure from the page's script: the front end sees a network error with no status and no body. When the marketing site's signup form reports "network error" only for large uploads or slow requests, the likeliest fault is a response produced outside Laravel's pipeline, not a wrong origin in `config/cors.php`. That pattern — some failures readable, others surfacing as CORS errors — is itself the clue. A wrong origin or an uncovered path breaks **every** response on that route, successful or not. Breakage that tracks request size, duration or a specific malformed URL points at a layer outside `HandleCors`. Front-end teams tend to file these as "CORS is flaky"; the fix lives in server limits, error handling or proxy configuration, and editing `allowed_origins` only adds noise.
- Why does a PostTooLargeException from Laravel carry CORS headers while a web server's 413 does not?`ValidatePostSize` sits after `HandleCors` in the global stack. When the body reaches PHP but exceeds `post_max_size`, the middleware throws `PostTooLargeException` inside `HandleCors`, and the rendered `413` passes back through it. A limit enforced by the web server rejects the body before PHP runs at all.
- Could editing config/cors.php appear to have no effect in production?Yes. When configuration is cached, `HandleCors` reads the cached `cors` values, and edits to the file are ignored until the cache is rebuilt. That symptom affects every response, successful or failed, which is how you tell it apart from the bypass cases.
saying these in an interview costs you the question
- Laravel's exception handler strips CORS headers from 5xx responses.
- Error responses skip the global middleware on the way out.
- A CORS error on a failed call means the origin is missing from allowed_origins.
- HandleCors adds headers only to successful responses.
- Adding CORS headers in both the proxy and Laravel is a harmless belt-and-braces setup.