CORS
CORS is browser-enforced, not a server-side authorization boundary: a non-preflight request from a
disallowed origin still reaches your controller (curl, mobile apps, and server-to-server calls send
no meaningful Origin at all) but gets no Access-Control-* headers, so a
browser blocks client-side JS from reading the response. A preflight (OPTIONS) request
from a disallowed origin, whose whole purpose is to ask permission, gets an explicit
403.
Wiring it
final class CORS extends \Monad\Clarity\Middlewares\CORS
{
public function __construct()
{
$origins = (string) getenv('CORS_ALLOWED_ORIGINS');
parent::__construct(
allowedOrigins: $origins !== '' ? array_map('trim', explode(',', $origins)) : ['*'],
);
}
}
new CORS(
array $allowedOrigins = ['*'], // ['*'] allows any; '*' is ignored if $supportsCredentials is true — the spec forbids that pair
array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
array $allowedHeaders = ['Content-Type', 'Authorization'],
array $exposedHeaders = [],
bool $supportsCredentials = false,
int $preflightCacheSeconds = 86400,
)
Register OPTIONS too, or preflight never runs
Every option here is plain constructor config with no global state — a route needing different
rules just registers its own differently-configured CORS instance. But
Route::dispatch() only runs a route's middleware once its method matches a registered
route; register only Route::get() and an OPTIONS preflight request gets a
plain 405 before CORS ever sees it:
Route::get('/api/me', [MeController::class, 'show'])->middleware($cors);
Route::options('/api/me', fn () => Response::noContent())->middleware($cors); // required for preflight
With both registered, a real preflight from an allowed origin returns 204 with the allow-headers; from a disallowed one, 403.
Next steps
- Routing — why an unregistered method 405s before middleware runs at all.