In Laravel's config/cors.php, what does the paths key decide, and why can a correctly listed origin still get no CORS headers?
answer
- checked before any origin logic
- same wildcard rules as $request->is()
- default: api/* and sanctum/csrf-cookie
- apiPrefix change escapes api/*
- unmatched preflight falls through to routing
basics
~20 sThe paths key decides which requests HandleCors handles at all, and it is checked first. A request whose path matches no entry gets no Access-Control headers whatever allowed_origins says, so the browser blocks a cross-origin call to it.
solid answer
~40 s`HandleCors` checks `paths` before anything else. Each entry is matched against the request path with the same `*` wildcard rules as `$request->is()`, and against the full URL too; surrounding slashes are trimmed. If nothing matches, the middleware calls `$next` and adds no headers, so `allowed_origins` is never consulted and the browser rejects the response. The usual culprits are an endpoint outside `api/*` (a `newsletter/subscribe` web route, `login`, `broadcasting/auth`), a `withRouting(apiPrefix: 'v1')` that moved the API to `/v1/...`, and a published file whose `paths` array replaced the default and dropped `sanctum/csrf-cookie`. The fix is to list every path the browser calls cross-origin, and nothing broader than it needs.
code
php · 11 lines<?php
// config/cors.php for a SaaS API whose routes use apiPrefix 'v1'
return [
'paths' => [
'v1/*',
'sanctum/csrf-cookie',
'newsletter/subscribe',
],
'allowed_origins' => [env('MARKETING_URL', 'https://www.tallyroom.example')],
];go deeper
Know that paths limits where CORS headers appear, and that api/* plus sanctum/csrf-cookie is the default.
Explain the Str::is wildcard matching, why an apiPrefix change or a web route escapes the default, and what an unmatched preflight receives.
Debug a blocked origin by checking paths before allowed_origins, and keep paths narrow and in step with routing changes across deploys.
Decide which route families are exposed cross-origin at all, and make changes to that surface reviewable rather than ad hoc edits.
## What paths is for `Illuminate\Http\Middleware\HandleCors` runs globally, on every request, but it only **acts** on requests whose path appears in the `paths` key of `config/cors.php`. That check comes first in its `handle()` method, ahead of any origin, method or header logic. Everything else in the file — `allowed_origins`, `supports_credentials`, `max_age` — applies only to requests that pass this gate. The framework default is `['api/*', 'sanctum/csrf-cookie']`. It covers the routes that `routes/api.php` registers under the default `api` prefix, plus the endpoint Sanctum's SPA flow calls to obtain its CSRF cookie. That ordering is why `paths` is the first thing to check when a browser reports a CORS error. A perfectly configured origin list does nothing for a request that never passes this gate, and the symptom — no `Access-Control-*` headers at all — looks exactly like an origin mismatch from the browser's side. ## How entries are matched `HandleCors` accepts an entry when `$request->is($path)` or `$request->fullUrlIs($path)` is true. Both delegate to `Str::is()`: - An entry is **trimmed of leading and trailing slashes** first, unless it is exactly `/`. Writing `/api/*` and `api/*` means the same thing. - `*` becomes a regular-expression `.*`, so it matches **any characters, slashes included**. `api/*` matches `api/v2/plans/7`, but not the bare path `api`. - A lone `'*'` matches every path. - Matching is **case-sensitive**, and the path excludes the query string. - An entry may be a **full URL pattern**, such as `https://api.tallyroom.example/*`, which is tested against the full URL. - `paths` may also hold arrays **keyed by host name**. When the request's host has an entry, only that host's list is used; otherwise only the plain string entries apply. ## What happens to an unmatched request 1. **Actual request.** `HandleCors` passes it on untouched. The route and controller run normally, and the response leaves without any `Access-Control-Allow-Origin`. The browser blocks the calling page from reading it. 2. **Preflight.** `HandleCors` does not answer it, so the `OPTIONS` request falls through to routing. Without route caching, the router answers an `OPTIONS` request for a URI that has routes on other methods with a `200` and an `Allow` header. That response still carries no CORS headers, so the preflight fails and the browser never sends the real request. Either way, the page's script sees a failed request, and the server-side logs can look perfectly healthy. ## Why a listed origin still gets nothing Take a marketing site at `https://www.tallyroom.example` calling a SaaS API at `https://api.tallyroom.example`: | Symptom | Cause | Fix | |---|---|---| | Newsletter signup to `/newsletter/subscribe` blocked | The route lives in `routes/web.php`, outside `api/*` | Add `newsletter/subscribe` to `paths` | | Every API call blocked after a refactor | `withRouting(apiPrefix: 'v1')` moved routes to `/v1/...` | Change the entry to `v1/*` | | Cookie login fails at the first step | A published `paths` of `['api/*']` dropped `sanctum/csrf-cookie` | Restore the entry | | Login posts blocked | `login` is a web route | Add `login` and `logout` | | Private channel auth blocked | `broadcasting/auth` is not covered | Add `broadcasting/auth` | Two traps sit behind the table. First, a published file's `paths` **replaces** the framework default outright: Laravel merges config files at the top level only. Second, `paths` is plain configuration. Changing `apiPrefix` in `bootstrap/app.php` does not update it. ## Choosing paths deliberately - List only the endpoints a browser on another origin actually calls. A same-origin Blade or Inertia app needs no CORS at all. - `['*']` is convenient but covers every web route, including pages that were never meant to be read cross-origin. - Prefer one wildcard per route family (`v1/*`) over one entry per endpoint. - Keep `sanctum/csrf-cookie` whenever a cookie-based front end calls the API from another origin; it is the first request of that flow. - Revisit `paths` in the same change that moves routes: a new `apiPrefix`, a route moved from `routes/api.php` to `routes/web.php`, or a new versioned family. A quick check sequence when an allowed origin reports a CORS error: 1. Run `php artisan config:show cors` and read the effective `paths` list. 2. Send the request by hand with an `Origin` header and look for any `Access-Control-*` header in the response. 3. If none comes back, the path is not covered, and `allowed_origins` was never consulted. Fix `paths` before touching anything else.
- Why does a paths entry of 'api' fail to cover /api/plans in Laravel?`HandleCors` tests entries with `Str::is()`, which is an exact comparison unless the entry holds a `*`. `api` equals only the bare path `api`, so `api/plans` does not match. Use `api/*`, whose asterisk matches any characters, slashes included.
- How would you give one host different CORS paths from another in a multi-host Laravel app?Key an array inside `paths` by host name, for example `'admin.tallyroom.example' => ['*']`. `HandleCors` looks up the request's host first. When it finds an entry it uses only that list; otherwise it falls back to the plain string entries in `paths`.
saying these in an interview costs you the question
- allowed_origins alone decides whether CORS headers are sent.
- The paths key follows the API prefix set in bootstrap/app.php automatically.
- A published paths array is merged with the framework's default entries.
- The entry 'api/*' also matches the bare path 'api'.
- HandleCors answers every preflight, whether or not its path is listed.