tenantcloud/laravel-cors
A Laravel package for handling Cross-Origin Resource Sharing (CORS). It provides middleware and configuration to control allowed origins, methods, headers, and credentials, helping APIs respond safely to browser preflight and cross-origin requests.
Version note: This assessment reflects v3.0.0, which is a breaking release — it requires Laravel 12 and PHP 8.4+. If you're on Laravel 10/11 or PHP 8.2/8.3, stay on the 2.4.x line (composer require "tenantcloud/laravel-cors:^2.4"); there is no compatibility shim or polyfill in v3.
Confirm your runtime before anything else:
php -v # must be >= 8.4
php artisan --version # must be 12.x
Install:
composer require tenantcloud/laravel-cors
Publish the config and read it — config/cors.php is the first file to look at, not the README:
php artisan vendor:publish --provider="TenantCloud\Cors\CorsServiceProvider" --tag="cors"
The keys that matter day-to-day: paths, allowed_origins, allowed_origins_patterns, allowed_methods, allowed_headers, exposed_headers, max_age, supports_credentials.
Register the middleware. On Laravel 12 the skeleton uses bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(\TenantCloud\Cors\HandleCors::class);
})
prepend matters — CORS headers must be added even when the request is later rejected by auth, throttle, or a 404.
First use case: a separately-hosted SPA (or partner integration) calling your API. Set paths => ['api/*', 'sanctum/csrf-cookie'], put your front-end origin in allowed_origins, then verify with:
curl -i -X OPTIONS https://api.test/api/orders \
-H "Origin: https://app.test" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
You should see Access-Control-Allow-Origin and Access-Control-Allow-Methods on a 204. If you don't, the middleware isn't running on that path — check paths first.
Global, path-scoped config. Keep one global middleware registration and scope by URL rather than per-route:
'paths' => ['api/*', 'oauth/token', 'storage/*'],
This is the lowest-friction pattern and survives route refactors. Reach for per-route middleware only when a few endpoints need genuinely different origins.
Per-route middleware for public vs. internal APIs. Split config into two files and swap at the route level:
Route::middleware('cors:public')->prefix('v1/public')->group(...);
Route::middleware('cors:internal')->prefix('v1/internal')->group(...);
This keeps a partner-facing allowlist small — never let a broad api/* origin list cover endpoints that accept session cookies.
Dynamic / multi-tenant origins. Tenant-scoped SaaS apps can't enumerate customer domains in a config file. Resolve the origin from the request instead of listing it:
'allowed_origins_patterns' => [
'#^https://([a-z0-9-]+)\.app\.test$#',
],
…or use a closure in allowed_origins that looks up the tenant's verified domains. Keep the closure cheap (cached lookup, no N+1) — it runs on every matched request, including preflights.
Credentials + SPA auth. Cookie/session-based SPAs need both sides set:
'supports_credentials' => true,
'allowed_origins' => ['https://app.test'],
'allowed_headers' => ['*'],
'exposed_headers' => ['X-Request-Id', 'RateLimit-Remaining'],
Anything the browser JS needs to read (pagination headers, request IDs, rate limits) must be in exposed_headers — otherwise it's invisible to your front-end even though it's on the wire.
Preflight bypass and route hygiene. Preflights carry no credentials, so any auth/can middleware on the route will 401 the OPTIONS request. Either let the CORS middleware short-circuit preflights (prepend it, as above) or register explicit Route::options(...) handlers. In practice: prepend and don't add auth to preflights.
Testing pattern. Assert headers explicitly, in both directions:
$this->withHeaders(['Origin' => 'https://app.test'])
->options('/api/orders')
->assertNoContent()
->assertHeader('Access-Control-Allow-Origin', 'https://app.test');
$this->withHeaders(['Origin' => 'https://evil.test'])
->getJson('/api/orders')
->assertHeaderMissing('Access-Control-Allow-Origin');
The negative test is the one that catches real misconfiguration.
Local dev with vite/localhost on varying ports. Add the port-bearing origins explicitly, or use a pattern for http://localhost:5\d{3}. Don't loosen production config to make local dev work — gate on app()->environment('local').
* and supports_credentials are mutually exclusive. The browser silently rejects Access-Control-Allow-Origin: * when credentials are included. With supports_credentials => true you must echo a concrete origin.
Trailing slashes break exact matches. https://app.test/ ≠ https://app.test. Same for header casing in patterns.
Only one origin is echoed. The package reflects the matched request origin — do not expect a comma-separated allowlist in the response header; browsers reject that.
Vary: Origin is essential behind a CDN. If your origin allowlist is per-request (patterns, closures, multi-tenant), confirm the response carries Vary: Origin, or a cache will serve one tenant's CORS headers to another. This is the classic multi-tenant CORS leak.
Config caching in production. php artisan config:cache is required after edits; a stale cached cors.php is the #1 cause of "works locally, fails in prod". Run php artisan config:clear when debugging.
Exception responses can bypass CORS. A fatal thrown before the middleware stack produces a 500 with no CORS headers, which the browser reports as a generic CORS error and hides the real cause. Check the server log, not the console.
Octane workers pin config per worker. After deploying new CORS config under Octane, php artisan octane:reload; otherwise some workers serve the old allowlist.
Debugging checklist, in order:
php artisan route:list -v for the route's middleware)paths? (api/* doesn't match /api itself)allowed_origins/patterns — exactly, including scheme and port?Access-Control-Allow-Origin but a 4xx)? Then auth is intercepting.Upgrading 2.4.x → 3.0.0: the diff is a platform-support bump, not an API rewrite, so the migration is mostly environmental — bump PHP to 8.4 and Laravel to 12 first, in a branch, then bump the constraint and re-run your CORS header tests. If you carry a forked config, diff it against the freshly published v3 config/cors.php; keys you rely on may have moved.
Extension points: the config file is the supported surface. If you need per-request decisions the patterns can't express (e.g. origins from a database with wildcard subdomains), resolve them in the allowed_origins closure rather than subclassing the middleware — subclassing couples you to internals that this major-version bump just proved can move.
Pin the major version (^3.0) and add a CI matrix job for PHP/Laravel so the next platform bump fails loudly at install time instead of at runtime.
How can I help you explore Laravel packages today?