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.