neomerx/cors-psr7
PSR-7 CORS implementation for PHP that parses requests and generates standards-compliant Cross-Origin Resource Sharing headers. Supports preflight and actual requests, configurable origins, methods, headers, credentials, and max age for easy middleware integration.
neomerx/cors-psr7 is not middleware — it is a CORS decision engine. You hand it a PSR-7 ServerRequestInterface plus a settings object, and it tells you two things: what kind of request this is (preflight, actual, simple, not-CORS) and which Access-Control-* headers belong on the response. Everything else — wiring, short-circuiting, header application — is yours to write.
Install it alongside a PSR-7 implementation, since the package deliberately ships none:
composer require neomerx/cors-psr7 nyholm/psr7
# Laravel: you also need the HttpFoundation <-> PSR-7 bridge
composer require symfony/psr-http-message-bridge
Where to look first, in this order:
src/Contracts/AnalysisResultInterface.php — the four request-type constants (TYPE_PREFLIGHT, TYPE_ACTUAL, TYPE_SIMPLE, TYPE_NOT_CORS) and the error codes. This enum is the whole contract you code against.src/Analyzer.php — Analyzer::instance($settings) and analyze($request).src/CorsSettings.php (v2) or src/Strategies/Settings.php (v1) — the fluent settings bag. Check which one your installed version exposes; this is the most common copy-paste failure.First use case — a single middleware that handles preflight and tags normal responses:
use Neomerx\Cors\Analyzer;
use Neomerx\Cors\CorsSettings;
use Neomerx\Cors\Contracts\AnalysisResultInterface as Result;
$settings = new CorsSettings();
$settings->setServerOrigin('https', 'api.example.com', 443);
$settings->setAllowedOrigins(['https://app.example.com']);
$settings->setAllowedMethods(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']);
$settings->setAllowedHeaders(['Content-Type', 'Authorization', 'X-Requested-With']);
$settings->setExposedHeaders(['X-Total-Count']);
$settings->setMaxAge(3600);
$settings->setCredentialsSupported(true);
$result = Analyzer::instance($settings)->analyze($psrRequest);
$result->getRequestType(); // Result::TYPE_PREFLIGHT, etc.
$result->getResponseHeaders(); // ['Access-Control-Allow-Origin' => [...], ...]
If getRequestType() returns TYPE_NOT_CORS (same-origin, or no Origin header at all), skip CORS entirely. If it returns TYPE_PREFLIGHT, respond 204 immediately without hitting the router.
1. Two-phase middleware: decide, then decorate. Do not interleave CORS logic with your handler. Analyze first, short-circuit preflight, otherwise call $next(), then merge the computed headers onto whatever response came back. This keeps the CORS code ~40 lines and testable with a fake PSR-7 request.
public function handle($request, Closure $next)
{
if (! $request->headers->has('Origin')) {
return $next($request);
}
$result = $this->analyzer()->analyze($this->toPsr($request));
$response = $result->getRequestType() === Result::TYPE_PREFLIGHT
? response('', 204)
: $next($request);
foreach ($result->getResponseHeaders() as $name => $values) {
$response->headers->set($name, (array) $values, false);
}
return $response;
}
The false third argument on headers->set() avoids clobbering an existing value; the (array) cast matters because the library returns header values as lists, not scalars.
2. Register the middleware globally, ahead of auth. Preflight OPTIONS requests carry no cookies and no Authorization, so any middleware that demands credentials will 401 the preflight and the browser will never send the real request. HandleCors must sit before Authenticate, before route-model binding, before throttling.
3. Drive settings from config, not code. Pull allowed_origins, allowed_methods, max_age, supports_credentials from config/cors.php and build the settings object in the middleware constructor or a small factory. This gives you per-environment origin lists (local dev ports vs. production) without touching code, and makes the settings object unit-testable in isolation.
4. Build settings per request when you need tenancy. The settings object is cheap; constructing one per request is fine and is the correct way to handle multi-tenant origin allowlists (tenant A's domain shouldn't be allowed on tenant B's API). Resolve the tenant from the request host or a header, then build. Do not hold a mutable singleton across requests in a long-running worker (Octane, RoadRunner) — you will leak tenant A's origins into tenant B's response.
5. For raw PSR-7 apps, apply to the response object directly. Same loop, but $response = $response->withHeader($name, $values) and, on a rejected preflight, withStatus(204). Keep the result object out of your controllers; it is a transport concern.
6. Log rejections. The analyzer accepts a PSR-3 logger. When a browser console shows "blocked by CORS policy" and you cannot tell why, a debug log of getRequestType() plus the origin and requested headers is the single highest-value five lines you will write. Silent rejection is the package's default behavior and its worst ergonomic property.
Laravel is not PSR-7. Illuminate\Http\Request is not ServerRequestInterface. You must convert with symfony/psr-http-message-bridge's HttpFoundationFactory and a PSR-17 factory (nyholm/psr7). Conversion allocates and copies the body stream — do it once per request, not per check. For Laravel 9+, use fruitcake/php-cors (what barryvdh/laravel-cors became) and skip this package entirely; you will only meet neomerx/cors-psr7 as a transitive dependency of older installs.
setServerOrigin() is what distinguishes same-origin from cross-origin. Behind a load balancer terminating TLS, the request looks like http://internal-host:80 while the browser sees https://api.example.com. If server origin doesn't match reality, legitimate same-origin calls get CORS headers and cross-origin detection can misfire. Either configure trusted proxies properly and derive the public scheme/host/port, or pass the values from config. Passing null for the port when it's the scheme default (80/443) is the right call.
Vary: Origin is not optional if anything caches. If a CDN or reverse proxy caches an API response that carries Access-Control-Allow-Origin: https://app.example.com, the next caller from a different origin gets the cached header and is blocked. Verify whether your version emits Vary and add it yourself if not — one line, saves an afternoon.
Credentials and wildcards are mutually exclusive. setCredentialsSupported(true) combined with '*' in allowed origins produces a response browsers reject outright. The library will happily generate the header; the browser is what refuses. Always enumerate origins explicitly when cookies or Authorization are involved.
Exposed headers are their own list. Access-Control-Allow-Headers governs what the request may send; setExposedHeaders() governs what JavaScript may read from the response. Custom pagination or rate-limit headers will appear in DevTools and be undefined in fetch() until you add them.
Rejected origins produce no exception. You get a result with no allow-origin header and a request type/error code you have to inspect. Do not try/catch — check getRequestType() and, if your version exposes it, the error accessors. Write the middleware so a rejection is a normal path, not a 500.
Case and whitespace in requested headers. Browsers lowercase header names in Access-Control-Request-Headers; your allowlist is likely mixed-case. The library normalizes, but if you roll your own comparison around it, normalize both sides.
Extension point: implement the strategy interface. If exact-string origin matching isn't enough (wildcard subdomains, regex, per-key allowlists from a database), implement the package's settings/analysis-strategy contract and pass your class to Analyzer::instance(). This is cleaner than post-processing the header array, and keeps the preflight logic intact.
Version risk. Last release was November 2022 and the dependent count is effectively zero. It is stable, spec-faithful, and small — but expect no fixes and no PHP-version churn support. Pin the exact version in composer.json and budget for replacing it with fruitcake/php-cors or ~50 lines of hand-rolled middleware if you upgrade past PHP 8.3. The CORS spec's core surface is small enough that the library's main value is the preflight edge cases, not the code volume.
Test in a browser, not curl. curl -H "Origin: ..." produces a request that never triggers preflight. Only a real cross-origin fetch with a non-simple header exercises the two-request flow you actually need to debug.
How can I help you explore Laravel packages today?