medz/cors
CORS middleware for Laravel that adds configurable Cross-Origin Resource Sharing headers, handles preflight OPTIONS requests, and supports allowed origins, methods, headers, credentials, and max-age settings. Easy to register and customize for APIs and web apps.
1. Install (Laravel 5.5–7 era; see gotchas for 8+)
composer require medz/cors
The service provider is auto-discovered. On Lumen you must register it manually in bootstrap/app.php:
$app->register(Medz\Cors\Laravel\CorsServiceProvider::class);
$app->routeMiddleware([
'cors' => Medz\Cors\Laravel\Middlewares\Cors::class,
]);
$app->configure('cors');
2. Publish and read the config first
php artisan vendor:publish --tag=cors
This is the first place to look — the entire behaviour of the package is this one file. Everything else in the middleware is a thin reader of it. Keys follow the barryvdh/laravel-cors camelCase style (paths, allowedOrigins, allowedOriginsPatterns, allowedMethods, allowedHeaders, exposedHeaders, maxAge, supportsCredentials), so verify the exact spelling against the file you actually publish rather than copying snippets from blog posts.
3. Register the route middleware
In app/Http/Kernel.php:
protected $routeMiddleware = [
// ...
'cors' => \Medz\Cors\Laravel\Middlewares\Cors::class,
];
4. First use case — a versioned JSON API consumed by a separate SPA
// config/cors.php
return [
'paths' => ['api/*'],
'supportsCredentials' => false,
'allowedOrigins' => ['https://app.example.com', 'http://localhost:3000'],
'allowedOriginsPatterns' => [],
'allowedMethods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposedHeaders' => [],
'maxAge' => 3600,
];
// routes/api.php
Route::middleware('cors')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
});
Hit it once with curl -i -X OPTIONS http://localhost/api/posts -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: GET" — you should get a 204 with Access-Control-Allow-Origin present. That single curl is your fastest smoke test for every config change.
Scope by path, not globally. The paths key already filters, but attaching cors per route group is clearer and keeps the middleware out of web (session/cookie) traffic where a * origin policy quietly makes credentialed requests unsafe:
Route::prefix('api')->middleware('cors')->group(base_path('routes/api.php'));
Credentials-required SPA/backoffice. If you serve cookies or Authorization with withCredentials, you must (a) set supportsCredentials => true and (b) enumerate exact origins — * is rejected by the browser in that combination. Echoing a specific origin also means the response must carry Vary: Origin; if you're behind Cloudflare/nginx microcaching, confirm the header is present or a cached response will hand the wrong origin to the next client.
Wildcard subdomains. Rather than generating a huge origin array, use allowedOriginsPatterns with a regex, e.g. '#^https://([a-z0-9-]+)\.example\.com$#'. Keep the pattern anchored and scheme-qualified or you'll over-permit.
Expose pagination/custom headers. Anything your frontend reads via response.headers.get(...) beyond the CORS-safelisted set needs to be listed in exposedHeaders — typical culprits are X-Total-Count, X-RateLimit-Remaining, Content-Disposition (file downloads), and Location.
Reuse one CORS policy across services. The real differentiator of this package over fruitcake/php-cors is that the same Cors core is wrapped for PSR-15, Slim, Swoft and ThinkPHP. If you have a Laravel API plus a Slim/PSR-15 microservice, you can ship an identical header policy to both instead of maintaining two config dialects. Instantiate the PSR-15 wrapper with the same array you keep in config/cors.php (shared via a small config package or env).
Preflight is handled for you — don't hand-roll it. The middleware intercepts OPTIONS with Access-Control-Request-Method and short-circuits with 204 + the allow-headers/methods/max-age trio. Do not add a catch-all Route::options('{any}') "just in case"; it will mask whether the middleware is actually wired up.
Testing. Laravel's test client runs middleware, so assert the actual headers rather than trusting config:
$response = $this->withHeaders(['Origin' => 'https://app.example.com'])
->options('/api/posts', [], ['Access-Control-Request-Method' => 'GET']);
$response->assertStatus(204);
$response->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com');
Add one test per origin class (allowed, disallowed, pattern-matched) — CORS regressions are silent in PHP and only visible in a browser console.
Ordering relative to auth. Place cors before auth/throttle in the middleware stack so preflights never hit authentication. If you rely on $middlewarePriority in Kernel.php, insert the CORS middleware near the top.
It is archived (last release May 2020). Treat this as a legacy-stack choice. On Laravel 9+ the framework ships fruitcake/php-cors and HandleCors out of the box; adding medz/cors there gives you two middlewares writing Access-Control-* headers, and duplicates/mismatches are a classic phantom bug. Decide deliberately: new Laravel project → use the built-in; existing Laravel 5.5–7 or Lumen app → this package is fine; anything on PHP 8.1+ → budget time for deprecation/type errors and consider forking or migrating.
Config cache. php artisan config:cache snapshots config/cors.php. Every origin edit needs a re-cache (or config:clear in dev). "I changed the config and nothing happened" is almost always this, or a stale preflight in the browser.
Browser preflight cache. maxAge caches the preflight result client-side. During development set maxAge => 0 and use DevTools "Disable cache" / a hard reload; otherwise you'll debug a problem that only exists in a cached OPTIONS response.
allowedOrigins entries are full origins. https://app.example.com — scheme required, no trailing slash, no path, port only when non-default (http://localhost:3000). example.com silently never matches. Case-sensitivity of scheme/host also bites.
nginx can eat your preflight. If OPTIONS returns 405 and the body looks like an nginx error page, the request never reached PHP. Ensure the location block forwards OPTIONS (if ($request_method = OPTIONS) { return 204; } blocks need matching CORS headers added manually, or remove them).
Non-standard ports and redirects. An HTTP→HTTPS redirect strips or rewrites the Origin; a preflight answered by the redirect target has no CORS headers and the browser reports a generic network error. Terminate redirects before the CORS middleware, or exempt OPTIONS.
Lumen specifics. No auto-discovery, no config:cache parity — $app->configure('cors') is mandatory or the middleware falls back to an empty/default policy and the browser sees no headers at all. Also verify the paths filter still works, since Lumen's router differs from Laravel's.
Debugging tip. Wrap curl around the exact browser request: send Origin, Access-Control-Request-Method, and Access-Control-Request-Headers on an OPTIONS, then replay the real verb with the same Origin. Ninety percent of "CORS errors" become obvious when you see whether the header is absent, * when it must be specific, or a single-origin value on a multi-origin app.
Extension points. The header logic sits behind the package's Cors contract, so if you need dynamic behaviour the config can't express (per-tenant origin allowlists driven by a DB table, signed-origin tokens), bind your own implementation over the interface in a service provider rather than post-processing responses in another middleware — you keep the preflight short-circuit and the Vary handling intact. For simpler dynamic cases, resolving and mutating the Cors instance in a service provider boot or a lightweight middleware placed before cors is less invasive.
Escape hatch. If you only ever need this for Laravel, a ~40-line hand-written middleware reading config('cors') and adding Vary: Origin covers most of the surface area and removes an unmaintained dependency. The package earns its keep when you need the shared core across Laravel + PSR-15/Slim services, or when you want its preflight edge cases handled for you on a legacy Laravel version.
How can I help you explore Laravel packages today?