From 6a732f80f32febeb110f36cc493f519354dc2c2d Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 20 Mar 2026 18:47:29 +0000 Subject: [PATCH 1/5] Add per-route CORS configuration support Allow CORS options to be defined at the route level via a fluent cors() method, route group option, or #[Cors] attribute, giving fine-grained control over cross-origin policies without modifying the global CORS configuration. --- src/Illuminate/Http/Middleware/HandleCors.php | 92 +++++++ src/Illuminate/Routing/Attributes/Cors.php | 44 ++++ src/Illuminate/Routing/Route.php | 59 +++++ src/Illuminate/Routing/RouteGroup.php | 6 +- src/Illuminate/Routing/RouteRegistrar.php | 2 + .../Http/Middleware/RouteCorsTest.php | 235 ++++++++++++++++++ .../Routing/CompiledRouteCollectionTest.php | 19 ++ 7 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 src/Illuminate/Routing/Attributes/Cors.php create mode 100644 tests/Integration/Http/Middleware/RouteCorsTest.php diff --git a/src/Illuminate/Http/Middleware/HandleCors.php b/src/Illuminate/Http/Middleware/HandleCors.php index efabddeb6f5d..0f3f66641e36 100644 --- a/src/Illuminate/Http/Middleware/HandleCors.php +++ b/src/Illuminate/Http/Middleware/HandleCors.php @@ -6,6 +6,7 @@ use Fruitcake\Cors\CorsService; use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; +use Illuminate\Routing\Route; class HandleCors { @@ -57,6 +58,28 @@ public function handle($request, Closure $next) } } + $routeOptions = $this->resolveRouteCorsOptions($request); + + if ($routeOptions !== null) { + $this->cors->setOptions($this->normalizeCorsOptions($routeOptions)); + + if ($this->cors->isPreflightRequest($request)) { + $response = $this->cors->handlePreflightRequest($request); + + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); + + return $response; + } + + $response = $next($request); + + if ($request->getMethod() === 'OPTIONS') { + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); + } + + return $this->cors->addActualRequestHeaders($response, $request); + } + if (! $this->hasMatchingPath($request)) { return $next($request); } @@ -80,6 +103,75 @@ public function handle($request, Closure $next) return $this->cors->addActualRequestHeaders($response, $request); } + /** + * Resolve route-level CORS options from the matched or matchable route. + * + * @param \Illuminate\Http\Request $request + * @return array|null + */ + protected function resolveRouteCorsOptions(Request $request): ?array + { + $route = $this->matchRouteForCors($request); + + return $route?->effectiveCorsOptions(); + } + + /** + * Attempt to match a route for the purpose of CORS resolution. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Routing\Route|null + */ + protected function matchRouteForCors(Request $request): ?Route + { + $router = $this->container['router']; + + if ($request->getMethod() !== 'OPTIONS') { + try { + return $router->getRoutes()->match($request); + } catch (\Throwable) { + return null; + } + } + + $intendedMethod = $request->headers->get('Access-Control-Request-Method'); + + if (! $intendedMethod) { + return null; + } + + $derivedRequest = Request::create( + $request->getUri(), + $intendedMethod, + server: $request->server->all(), + ); + + try { + return $router->getRoutes()->match($derivedRequest); + } catch (\Throwable) { + return null; + } + } + + /** + * Normalize short-form CORS options into the shape expected by CorsService. + * + * @param array $options + * @return array + */ + protected function normalizeCorsOptions(array $options): array + { + return [ + 'allowed_origins' => $options['origins'] ?? ['*'], + 'allowed_methods' => $options['methods'] ?? ['*'], + 'allowed_headers' => $options['headers'] ?? ['*'], + 'exposed_headers' => $options['exposed_headers'] ?? [], + 'max_age' => $options['max_age'] ?? 0, + 'supports_credentials' => $options['credentials'] ?? false, + 'allowed_origins_patterns' => [], + ]; + } + /** * Get the path from the configuration to determine if the CORS service should run. * diff --git a/src/Illuminate/Routing/Attributes/Cors.php b/src/Illuminate/Routing/Attributes/Cors.php new file mode 100644 index 000000000000..5356c6a59d41 --- /dev/null +++ b/src/Illuminate/Routing/Attributes/Cors.php @@ -0,0 +1,44 @@ +|null $origins + * @param array|null $methods + * @param array|null $headers + * @param array|null $exposed_headers + * @param int|null $max_age + * @param bool|null $credentials + */ + public function __construct( + public ?array $origins = null, + public ?array $methods = null, + public ?array $headers = null, + public ?array $exposed_headers = null, + public ?int $max_age = null, + public ?bool $credentials = null, + ) { + } + + /** + * Get the CORS options as an array, excluding null values. + * + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'origins' => $this->origins, + 'methods' => $this->methods, + 'headers' => $this->headers, + 'exposed_headers' => $this->exposed_headers, + 'max_age' => $this->max_age, + 'credentials' => $this->credentials, + ], fn ($value) => ! is_null($value)); + } +} diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index 341d07f0e36b..fd7ce8dc7864 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -1106,6 +1106,65 @@ public function can($ability, $models = []) : $this->middleware(['can:'.$ability.','.implode(',', Arr::wrap($models))]); } + /** + * Set the CORS options for the route. + * + * @param array $options + * @return $this + */ + public function cors(array $options) + { + $this->action['cors'] = $options; + + return $this; + } + + /** + * Get the effective CORS options for this route. + * + * Resolution order: method attribute > class attribute > route/group action. + * + * @return array|null + */ + public function effectiveCorsOptions() + { + return $this->controllerCorsOptions() + ?? ($this->action['cors'] ?? null); + } + + /** + * Get CORS options from controller attributes. + * + * @return array|null + */ + protected function controllerCorsOptions() + { + if (! $this->isControllerAction()) { + return null; + } + + try { + $reflectionClass = new ReflectionClass($this->getControllerClass()); + $reflectionMethod = $reflectionClass->getMethod($this->getControllerMethod()); + } catch (ReflectionException) { + return null; + } + + $methodAttrs = $reflectionMethod->getAttributes(Attributes\Cors::class); + + if (! empty($methodAttrs)) { + return $methodAttrs[0]->newInstance()->toArray(); + } + + $classAttrs = $reflectionClass->getAttributes(Attributes\Cors::class); + + if (! empty($classAttrs)) { + return $classAttrs[0]->newInstance()->toArray(); + } + + return null; + } + /** * Get the middleware for the route's controller. * diff --git a/src/Illuminate/Routing/RouteGroup.php b/src/Illuminate/Routing/RouteGroup.php index cca24b29234d..662a5ec100f6 100644 --- a/src/Illuminate/Routing/RouteGroup.php +++ b/src/Illuminate/Routing/RouteGroup.php @@ -30,8 +30,12 @@ public static function merge($new, $old, $prependExistingPrefix = true) 'where' => static::formatWhere($new, $old), ]); + if (! isset($new['cors']) && isset($old['cors'])) { + $new['cors'] = $old['cors']; + } + return array_merge_recursive(Arr::except( - $old, ['namespace', 'prefix', 'where', 'as'] + $old, ['namespace', 'prefix', 'where', 'as', 'cors'] ), $new); } diff --git a/src/Illuminate/Routing/RouteRegistrar.php b/src/Illuminate/Routing/RouteRegistrar.php index 6da02f501fef..471357d5802c 100644 --- a/src/Illuminate/Routing/RouteRegistrar.php +++ b/src/Illuminate/Routing/RouteRegistrar.php @@ -31,6 +31,7 @@ * @method \Illuminate\Routing\RouteRegistrar where(array $where) * @method \Illuminate\Routing\RouteRegistrar withoutMiddleware(array|string $middleware) * @method \Illuminate\Routing\RouteRegistrar withoutScopedBindings() + * @method \Illuminate\Routing\RouteRegistrar cors(array $options) */ class RouteRegistrar { @@ -71,6 +72,7 @@ class RouteRegistrar 'as', 'can', 'controller', + 'cors', 'domain', 'middleware', 'missing', diff --git a/tests/Integration/Http/Middleware/RouteCorsTest.php b/tests/Integration/Http/Middleware/RouteCorsTest.php new file mode 100644 index 000000000000..6fcd50a6d251 --- /dev/null +++ b/tests/Integration/Http/Middleware/RouteCorsTest.php @@ -0,0 +1,235 @@ + ['api/*'], + 'supports_credentials' => false, + 'allowed_origins' => ['http://global.example.com'], + 'allowed_headers' => ['X-Global-Header'], + 'allowed_methods' => ['GET', 'POST'], + 'exposed_headers' => [], + 'max_age' => 0, + ]; + + $kernel = $app->make(Kernel::class); + $kernel->prependMiddleware(HandleCors::class); + } + + protected function defineRoutes($router) + { + $router->get('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET']]); + + $router->post('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET', 'POST']]); + + $router->get('api/global-cors', ['uses' => fn () => 'GLOBAL']); + + $router->get('api/sibling', ['uses' => fn () => 'SIBLING']) + ->cors(['origins' => ['https://sibling.example.com']]); + + $router->get('web/no-cors', ['uses' => fn () => 'WEB']); + + $router->prefix('api/grouped')->cors([ + 'origins' => ['https://group.example.com'], + 'methods' => ['GET', 'POST'], + ])->group(function (Router $router) { + $router->get('child', ['uses' => fn () => 'CHILD']); + + $router->get('override', ['uses' => fn () => 'OVERRIDE']) + ->cors(['origins' => ['https://override.example.com']]); + }); + + $router->get('api/controller-cors', [ControllerWithClassCors::class, 'index']); + + $router->get('api/method-cors', [ControllerWithMethodCors::class, 'specific']); + + $router->get('api/attr-over-route', [ControllerWithClassCors::class, 'index']) + ->cors(['origins' => ['https://route-loses.example.com']]); + } + + public function testRouteLevelCorsOnActualRequest() + { + $this->call('GET', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testRouteLevelCorsPreflightRequest() + { + $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testRouteLevelCorsPreflightWithPostMethod() + { + $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testFallbackToGlobalConfigWhenNoRouteCors() + { + $this->call('GET', 'api/global-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'http://global.example.com'); + } + + public function testFallbackToGlobalConfigOnPreflight() + { + $this->call('OPTIONS', 'api/global-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'http://global.example.com'); + } + + public function testRouteCorsDoesNotBleedIntoSiblingRoute() + { + $response = $this->call('GET', 'api/sibling', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + ]); + + $this->assertNotSame('https://app.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testRouteCorsReplacesGlobalDefaults() + { + $response = $this->call('GET', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + ]); + + $this->assertNotSame('http://global.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testNoCorsHeadersOnWebRoute() + { + $this->call('GET', 'web/no-cors', server: [ + 'HTTP_ORIGIN' => 'http://anywhere.com', + ])->assertOk() + ->assertHeaderMissing('Access-Control-Allow-Origin'); + } + + public function testGroupCorsAppliesToChildRoutes() + { + $this->call('GET', 'api/grouped/child', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testChildRouteCanOverrideGroupCors() + { + $this->call('GET', 'api/grouped/override', server: [ + 'HTTP_ORIGIN' => 'https://override.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://override.example.com'); + } + + public function testGroupCorsOriginsNotAllowedOnOverrideRoute() + { + $response = $this->call('GET', 'api/grouped/override', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + ]); + + $this->assertNotSame('https://group.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testControllerClassAttributeCors() + { + $this->call('GET', 'api/controller-cors', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testControllerMethodAttributeOverridesClassAttribute() + { + $this->call('GET', 'api/method-cors', server: [ + 'HTTP_ORIGIN' => 'https://method-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://method-attr.example.com'); + } + + public function testControllerAttributeOverridesRouteAction() + { + $this->call('GET', 'api/attr-over-route', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testRouteActionIgnoredWhenControllerAttributeExists() + { + $response = $this->call('GET', 'api/attr-over-route', server: [ + 'HTTP_ORIGIN' => 'https://route-loses.example.com', + ]); + + $this->assertNotSame('https://route-loses.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testPreflightOnGroupCorsRoute() + { + $this->call('OPTIONS', 'api/grouped/child', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testPreflightOnControllerAttributeRoute() + { + $this->call('OPTIONS', 'api/controller-cors', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testRouteCorsWithCredentials() + { + $this->app['router']->get('api/creds', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://creds.example.com'], 'credentials' => true]); + + $this->call('GET', 'api/creds', server: [ + 'HTTP_ORIGIN' => 'https://creds.example.com', + ])->assertHeader('Access-Control-Allow-Credentials', 'true'); + } +} + +#[Cors(origins: ['https://class-attr.example.com'])] +class ControllerWithClassCors +{ + public function index() + { + return 'CLASS_CORS'; + } +} + +#[Cors(origins: ['https://class-attr.example.com'])] +class ControllerWithMethodCors +{ + #[Cors(origins: ['https://method-attr.example.com'])] + public function specific() + { + return 'METHOD_CORS'; + } +} diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 355df95d81ca..abb496b9c2e9 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -571,6 +571,25 @@ public function testRouteWithSamePathAndSameMethodButDiffDomainNameWithOptionsMe ], $this->collection()->getRoutesByMethod()); } + public function testCorsMetadataSurvivesCompiledRouteCollection() + { + $corsOptions = ['origins' => ['https://app.example.com'], 'methods' => ['GET']]; + + $this->routeCollection->add( + $this->newRoute('GET', 'api/cors-test', [ + 'uses' => 'CorsController@index', + 'cors' => $corsOptions, + ]) + ); + + $compiled = $this->collection(); + + $request = Request::create('http://localhost/api/cors-test', 'GET'); + $route = $compiled->match($request); + + $this->assertSame($corsOptions, $route->getAction()['cors']); + } + /** * Create a new Route object. * From 089c2c893e3356ea9fd8ae3ca5343e94197c876d Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Sun, 22 Mar 2026 16:07:43 +0000 Subject: [PATCH 2/5] Refine route CORS middleware handling --- .../Foundation/Configuration/Middleware.php | 2 + src/Illuminate/Http/Middleware/HandleCors.php | 101 ++++++------------ .../Http/Middleware/HandleRouteCors.php | 65 +++++++++++ .../Routing/AbstractRouteCollection.php | 30 ++++-- .../Configuration/MiddlewareTest.php | 11 ++ .../Http/Middleware/RouteCorsTest.php | 61 +++++++---- 6 files changed, 169 insertions(+), 101 deletions(-) create mode 100644 src/Illuminate/Http/Middleware/HandleRouteCors.php diff --git a/src/Illuminate/Foundation/Configuration/Middleware.php b/src/Illuminate/Foundation/Configuration/Middleware.php index c4d90bb98850..720a7a3e7b32 100644 --- a/src/Illuminate/Foundation/Configuration/Middleware.php +++ b/src/Illuminate/Foundation/Configuration/Middleware.php @@ -483,6 +483,7 @@ public function getMiddlewareGroups() { $middleware = [ 'web' => array_values(array_filter([ + \Illuminate\Http\Middleware\HandleRouteCors::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, @@ -493,6 +494,7 @@ public function getMiddlewareGroups() ])), 'api' => array_values(array_filter([ + \Illuminate\Http\Middleware\HandleRouteCors::class, $this->statefulApi ? \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class : null, $this->apiLimiter ? 'throttle:'.$this->apiLimiter : null, \Illuminate\Routing\Middleware\SubstituteBindings::class, diff --git a/src/Illuminate/Http/Middleware/HandleCors.php b/src/Illuminate/Http/Middleware/HandleCors.php index 0f3f66641e36..8949a9f24c76 100644 --- a/src/Illuminate/Http/Middleware/HandleCors.php +++ b/src/Illuminate/Http/Middleware/HandleCors.php @@ -6,10 +6,11 @@ use Fruitcake\Cors\CorsService; use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; -use Illuminate\Routing\Route; class HandleCors { + public const ROUTE_CORS_HANDLED_ATTRIBUTE = '_laravel_route_cors_handled'; + /** * The container instance. * @@ -52,105 +53,63 @@ public function __construct(Container $container, CorsService $cors) */ public function handle($request, Closure $next) { - foreach (static::$skipCallbacks as $callback) { - if ($callback($request)) { - return $next($request); - } - } - - $routeOptions = $this->resolveRouteCorsOptions($request); - - if ($routeOptions !== null) { - $this->cors->setOptions($this->normalizeCorsOptions($routeOptions)); - - if ($this->cors->isPreflightRequest($request)) { - $response = $this->cors->handlePreflightRequest($request); - - $this->cors->varyHeader($response, 'Access-Control-Request-Method'); - - return $response; - } - - $response = $next($request); - - if ($request->getMethod() === 'OPTIONS') { - $this->cors->varyHeader($response, 'Access-Control-Request-Method'); - } - - return $this->cors->addActualRequestHeaders($response, $request); - } - - if (! $this->hasMatchingPath($request)) { + if ($this->shouldSkip($request) || ! $this->hasMatchingPath($request)) { return $next($request); } - $this->cors->setOptions($this->container['config']->get('cors', [])); - - if ($this->cors->isPreflightRequest($request)) { - $response = $this->cors->handlePreflightRequest($request); - - $this->cors->varyHeader($response, 'Access-Control-Request-Method'); - - return $response; - } - $response = $next($request); - if ($request->getMethod() === 'OPTIONS') { - $this->cors->varyHeader($response, 'Access-Control-Request-Method'); + if ($request->attributes->get(static::ROUTE_CORS_HANDLED_ATTRIBUTE)) { + return $response; } - return $this->cors->addActualRequestHeaders($response, $request); + return $this->handleRequest($request, fn () => $response, $this->container['config']->get('cors', [])); } /** - * Resolve route-level CORS options from the matched or matchable route. + * Determine whether the middleware should be skipped. * * @param \Illuminate\Http\Request $request - * @return array|null + * @return bool */ - protected function resolveRouteCorsOptions(Request $request): ?array + protected function shouldSkip(Request $request): bool { - $route = $this->matchRouteForCors($request); + foreach (static::$skipCallbacks as $callback) { + if ($callback($request)) { + return true; + } + } - return $route?->effectiveCorsOptions(); + return false; } /** - * Attempt to match a route for the purpose of CORS resolution. + * Handle the request using the given CORS options. * * @param \Illuminate\Http\Request $request - * @return \Illuminate\Routing\Route|null + * @param \Closure $next + * @param array $options + * @return \Illuminate\Http\Response */ - protected function matchRouteForCors(Request $request): ?Route + protected function handleRequest(Request $request, Closure $next, array $options) { - $router = $this->container['router']; + $this->cors->setOptions($options); - if ($request->getMethod() !== 'OPTIONS') { - try { - return $router->getRoutes()->match($request); - } catch (\Throwable) { - return null; - } - } + if ($this->cors->isPreflightRequest($request)) { + $response = $this->cors->handlePreflightRequest($request); - $intendedMethod = $request->headers->get('Access-Control-Request-Method'); + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); - if (! $intendedMethod) { - return null; + return $response; } - $derivedRequest = Request::create( - $request->getUri(), - $intendedMethod, - server: $request->server->all(), - ); + $response = $next($request); - try { - return $router->getRoutes()->match($derivedRequest); - } catch (\Throwable) { - return null; + if ($request->getMethod() === 'OPTIONS') { + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); } + + return $this->cors->addActualRequestHeaders($response, $request); } /** diff --git a/src/Illuminate/Http/Middleware/HandleRouteCors.php b/src/Illuminate/Http/Middleware/HandleRouteCors.php new file mode 100644 index 000000000000..ab76ce7ed925 --- /dev/null +++ b/src/Illuminate/Http/Middleware/HandleRouteCors.php @@ -0,0 +1,65 @@ +shouldSkip($request)) { + return $next($request); + } + + $routeOptions = $this->resolveRouteCorsOptions($request); + + if ($routeOptions === null) { + return $next($request); + } + + $request->attributes->set(static::ROUTE_CORS_HANDLED_ATTRIBUTE, true); + + return $this->handleRequest($request, $next, $this->normalizeCorsOptions($routeOptions)); + } + + /** + * Resolve the CORS options for the current route. + * + * @param \Illuminate\Http\Request $request + * @return array|null + */ + protected function resolveRouteCorsOptions(Request $request): ?array + { + $route = $request->route(); + + if (! $route instanceof Route) { + return null; + } + + if (! $request->isMethod('OPTIONS')) { + return $route->effectiveCorsOptions(); + } + + $intendedMethod = strtoupper((string) $request->headers->get('Access-Control-Request-Method')); + + if ($intendedMethod !== '') { + $alternateRoute = $route->getAction('cors_routes.'.$intendedMethod); + + if ($alternateRoute instanceof Route) { + return $alternateRoute->effectiveCorsOptions(); + } + } + + return $route->effectiveCorsOptions(); + } +} diff --git a/src/Illuminate/Routing/AbstractRouteCollection.php b/src/Illuminate/Routing/AbstractRouteCollection.php index 6f7c73c61ee2..90b377ef9a32 100644 --- a/src/Illuminate/Routing/AbstractRouteCollection.php +++ b/src/Illuminate/Routing/AbstractRouteCollection.php @@ -51,7 +51,7 @@ protected function handleMatchedRoute(Request $request, $route) * Determine if any routes match on another HTTP verb. * * @param \Illuminate\Http\Request $request - * @return array + * @return array */ protected function checkForAlternateVerbs($request) { @@ -60,11 +60,9 @@ protected function checkForAlternateVerbs($request) // Here we will spin through all verbs except for the current request verb and // check to see if any routes respond to them. If they do, we will return a // proper error response with the correct headers on the response string. - return array_values(array_filter( + return array_filter(array_combine( $methods, - function ($method) use ($request) { - return ! is_null($this->matchAgainstRoutes($this->get($method), $request, false)); - } + array_map(fn ($method) => $this->matchAgainstRoutes($this->get($method), $request, false), $methods) )); } @@ -99,17 +97,31 @@ protected function matchAgainstRoutes(array $routes, $request, $includingMethod * Get a route (if necessary) that responds when other available methods are present. * * @param \Illuminate\Http\Request $request - * @param string[] $methods + * @param array $routes * @return \Illuminate\Routing\Route * * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException */ - protected function getRouteForMethods($request, array $methods) + protected function getRouteForMethods($request, array $routes) { + $methods = array_keys($routes); + if ($request->isMethod('OPTIONS')) { - return (new Route('OPTIONS', $request->path(), function () use ($methods) { + $route = new Route('OPTIONS', $request->path(), function () use ($methods) { return new Response('', 200, ['Allow' => implode(',', $methods)]); - }))->bind($request); + }); + + $route->setAction([ + 'uses' => $route->getAction('uses'), + 'controller' => $route->getAction('controller'), + 'middleware' => array_values(array_unique(array_merge(...array_map( + fn ($alternateRoute) => (array) $alternateRoute->getAction('middleware'), + array_values($routes), + )))), + 'cors_routes' => $routes, + ]); + + return $route->bind($request); } $this->requestMethodNotAllowed($request, $methods, $request->method()); diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index ecf0c0e85b00..f73b448dbc5b 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Illuminate\Foundation\Http\Middleware\TrimStrings; +use Illuminate\Http\Middleware\HandleRouteCors; use Illuminate\Http\Middleware\TrustHosts; use Illuminate\Http\Middleware\TrustProxies; use Illuminate\Http\Request; @@ -346,4 +347,14 @@ public function testRedirectGuestsToNullRegistersNullCallback() $this->assertNotNull($callback); $this->assertNull($callback(null)); } + + public function testRouteCorsMiddlewareIsPrependedToWebAndApiGroups() + { + $configuration = new Middleware(); + + $groups = $configuration->getMiddlewareGroups(); + + $this->assertSame(HandleRouteCors::class, $groups['web'][0]); + $this->assertSame(HandleRouteCors::class, $groups['api'][0]); + } } diff --git a/tests/Integration/Http/Middleware/RouteCorsTest.php b/tests/Integration/Http/Middleware/RouteCorsTest.php index 6fcd50a6d251..408717cd1f13 100644 --- a/tests/Integration/Http/Middleware/RouteCorsTest.php +++ b/tests/Integration/Http/Middleware/RouteCorsTest.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Http\Middleware\HandleCors; +use Illuminate\Http\Middleware\HandleRouteCors; use Illuminate\Routing\Attributes\Cors; use Illuminate\Routing\Router; use Orchestra\Testbench\TestCase; @@ -22,41 +23,49 @@ protected function defineEnvironment($app) 'max_age' => 0, ]; + $app['config']['app.key'] = 'base64:'.base64_encode(str_repeat('a', 32)); + $kernel = $app->make(Kernel::class); $kernel->prependMiddleware(HandleCors::class); + $kernel->prependMiddlewareToGroup('web', HandleRouteCors::class); + $kernel->prependMiddlewareToGroup('api', HandleRouteCors::class); } protected function defineRoutes($router) { - $router->get('api/route-cors', ['uses' => fn () => 'OK']) - ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET']]); + $router->middleware('api')->group(function (Router $router) { + $router->get('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET']]); - $router->post('api/route-cors', ['uses' => fn () => 'OK']) - ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET', 'POST']]); + $router->post('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET', 'POST']]); - $router->get('api/global-cors', ['uses' => fn () => 'GLOBAL']); + $router->get('api/global-cors', ['uses' => fn () => 'GLOBAL']); - $router->get('api/sibling', ['uses' => fn () => 'SIBLING']) - ->cors(['origins' => ['https://sibling.example.com']]); + $router->get('api/sibling', ['uses' => fn () => 'SIBLING']) + ->cors(['origins' => ['https://sibling.example.com']]); - $router->get('web/no-cors', ['uses' => fn () => 'WEB']); + $router->prefix('api/grouped')->cors([ + 'origins' => ['https://group.example.com'], + 'methods' => ['GET', 'POST'], + ])->group(function (Router $router) { + $router->get('child', ['uses' => fn () => 'CHILD']); - $router->prefix('api/grouped')->cors([ - 'origins' => ['https://group.example.com'], - 'methods' => ['GET', 'POST'], - ])->group(function (Router $router) { - $router->get('child', ['uses' => fn () => 'CHILD']); + $router->get('override', ['uses' => fn () => 'OVERRIDE']) + ->cors(['origins' => ['https://override.example.com']]); + }); - $router->get('override', ['uses' => fn () => 'OVERRIDE']) - ->cors(['origins' => ['https://override.example.com']]); - }); + $router->get('api/controller-cors', [ControllerWithClassCors::class, 'index']); - $router->get('api/controller-cors', [ControllerWithClassCors::class, 'index']); + $router->get('api/method-cors', [ControllerWithMethodCors::class, 'specific']); - $router->get('api/method-cors', [ControllerWithMethodCors::class, 'specific']); + $router->get('api/attr-over-route', [ControllerWithClassCors::class, 'index']) + ->cors(['origins' => ['https://route-loses.example.com']]); + }); - $router->get('api/attr-over-route', [ControllerWithClassCors::class, 'index']) - ->cors(['origins' => ['https://route-loses.example.com']]); + $router->middleware('web')->group(function (Router $router) { + $router->get('web/no-cors', ['uses' => fn () => 'WEB']); + }); } public function testRouteLevelCorsOnActualRequest() @@ -120,6 +129,16 @@ public function testRouteCorsReplacesGlobalDefaults() $this->assertNotSame('http://global.example.com', $response->headers->get('Access-Control-Allow-Origin')); } + public function testRouteCorsPreflightDoesNotFallBackToGlobalDefaults() + { + $response = $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ]); + + $this->assertNotSame('http://global.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + public function testNoCorsHeadersOnWebRoute() { $this->call('GET', 'web/no-cors', server: [ @@ -206,7 +225,7 @@ public function testPreflightOnControllerAttributeRoute() public function testRouteCorsWithCredentials() { - $this->app['router']->get('api/creds', ['uses' => fn () => 'OK']) + $this->app['router']->middleware('api')->get('api/creds', ['uses' => fn () => 'OK']) ->cors(['origins' => ['https://creds.example.com'], 'credentials' => true]); $this->call('GET', 'api/creds', server: [ From bde9769bc2644c310ee5fbe9d44955075813a0c9 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 21 Apr 2026 15:48:52 +0100 Subject: [PATCH 3/5] Tighten per-route CORS middleware after review - Narrow preflight lookup catch to routing exceptions - Run preflight option resolution only on preflight requests - Unify preflight detection via CorsService::isPreflightRequest - Drop dead OPTIONS branch and synthetic cors_routes action - Add throttle preflight tests and tighten Allow header assertion --- src/Illuminate/Http/Middleware/HandleCors.php | 45 +++++++++ .../Http/Middleware/HandleRouteCors.php | 16 +--- .../Routing/AbstractRouteCollection.php | 16 +--- .../Http/Middleware/HandleCorsTest.php | 92 +++++++++++++++++++ .../Http/Middleware/RouteCorsTest.php | 76 +++++++++++++++ 5 files changed, 216 insertions(+), 29 deletions(-) diff --git a/src/Illuminate/Http/Middleware/HandleCors.php b/src/Illuminate/Http/Middleware/HandleCors.php index 8949a9f24c76..5f39c84042b6 100644 --- a/src/Illuminate/Http/Middleware/HandleCors.php +++ b/src/Illuminate/Http/Middleware/HandleCors.php @@ -6,6 +6,9 @@ use Fruitcake\Cors\CorsService; use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; +use Illuminate\Routing\Route; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class HandleCors { @@ -57,6 +60,16 @@ public function handle($request, Closure $next) return $next($request); } + if ($this->cors->isPreflightRequest($request)) { + $this->cors->setOptions($this->resolveOptionsForPreflight($request)); + + $response = $this->cors->handlePreflightRequest($request); + + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); + + return $response; + } + $response = $next($request); if ($request->attributes->get(static::ROUTE_CORS_HANDLED_ATTRIBUTE)) { @@ -66,6 +79,38 @@ public function handle($request, Closure $next) return $this->handleRequest($request, fn () => $response, $this->container['config']->get('cors', [])); } + /** + * Resolve the CORS options to use when answering a preflight request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function resolveOptionsForPreflight(Request $request): array + { + $globalOptions = $this->container['config']->get('cors', []); + + $intendedMethod = strtoupper((string) $request->headers->get('Access-Control-Request-Method')); + + if ($intendedMethod === '') { + return $globalOptions; + } + + try { + $probe = $request->duplicate(); + $probe->setMethod($intendedMethod); + + $route = $this->container['router']->getRoutes()->match($probe); + } catch (NotFoundHttpException | MethodNotAllowedHttpException) { + return $globalOptions; + } + + if ($route instanceof Route && ($routeOptions = $route->effectiveCorsOptions()) !== null) { + return $this->normalizeCorsOptions($routeOptions); + } + + return $globalOptions; + } + /** * Determine whether the middleware should be skipped. * diff --git a/src/Illuminate/Http/Middleware/HandleRouteCors.php b/src/Illuminate/Http/Middleware/HandleRouteCors.php index ab76ce7ed925..884354c3052c 100644 --- a/src/Illuminate/Http/Middleware/HandleRouteCors.php +++ b/src/Illuminate/Http/Middleware/HandleRouteCors.php @@ -17,7 +17,7 @@ class HandleRouteCors extends HandleCors */ public function handle($request, Closure $next) { - if ($this->shouldSkip($request)) { + if ($this->shouldSkip($request) || $this->cors->isPreflightRequest($request)) { return $next($request); } @@ -46,20 +46,6 @@ protected function resolveRouteCorsOptions(Request $request): ?array return null; } - if (! $request->isMethod('OPTIONS')) { - return $route->effectiveCorsOptions(); - } - - $intendedMethod = strtoupper((string) $request->headers->get('Access-Control-Request-Method')); - - if ($intendedMethod !== '') { - $alternateRoute = $route->getAction('cors_routes.'.$intendedMethod); - - if ($alternateRoute instanceof Route) { - return $alternateRoute->effectiveCorsOptions(); - } - } - return $route->effectiveCorsOptions(); } } diff --git a/src/Illuminate/Routing/AbstractRouteCollection.php b/src/Illuminate/Routing/AbstractRouteCollection.php index 90b377ef9a32..385e4cde2080 100644 --- a/src/Illuminate/Routing/AbstractRouteCollection.php +++ b/src/Illuminate/Routing/AbstractRouteCollection.php @@ -107,21 +107,9 @@ protected function getRouteForMethods($request, array $routes) $methods = array_keys($routes); if ($request->isMethod('OPTIONS')) { - $route = new Route('OPTIONS', $request->path(), function () use ($methods) { + return (new Route('OPTIONS', $request->path(), function () use ($methods) { return new Response('', 200, ['Allow' => implode(',', $methods)]); - }); - - $route->setAction([ - 'uses' => $route->getAction('uses'), - 'controller' => $route->getAction('controller'), - 'middleware' => array_values(array_unique(array_merge(...array_map( - fn ($alternateRoute) => (array) $alternateRoute->getAction('middleware'), - array_values($routes), - )))), - 'cors_routes' => $routes, - ]); - - return $route->bind($request); + }))->bind($request); } $this->requestMethodNotAllowed($request, $methods, $request->method()); diff --git a/tests/Integration/Http/Middleware/HandleCorsTest.php b/tests/Integration/Http/Middleware/HandleCorsTest.php index 38ed647a621a..6abd2580d133 100644 --- a/tests/Integration/Http/Middleware/HandleCorsTest.php +++ b/tests/Integration/Http/Middleware/HandleCorsTest.php @@ -2,17 +2,29 @@ namespace Illuminate\Tests\Integration\Http\Middleware; +use Illuminate\Auth\Middleware\Authenticate; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Http\Kernel; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Request; +use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Orchestra\Testbench\TestCase; class HandleCorsTest extends TestCase { use ValidatesRequests; + protected function setUp(): void + { + parent::setUp(); + + SpyMiddleware::$invocations = 0; + } + protected function defineEnvironment($app) { $app['config']['cors'] = [ @@ -25,6 +37,11 @@ protected function defineEnvironment($app) 'max_age' => 0, ]; + $app['config']->set('auth.defaults.guard', 'web'); + $app['config']->set('auth.guards.web', ['driver' => 'handle-cors-null']); + + $app['auth']->viaRequest('handle-cors-null', fn () => null); + $kernel = $app->make(Kernel::class); $kernel->prependMiddleware(HandleCors::class); } @@ -245,6 +262,64 @@ public function testValidationException() $this->assertEquals(302, $crawler->getStatusCode()); } + public function testPreflightPassesThroughAuthMiddleware() + { + $crawler = $this->call('OPTIONS', 'api/protected', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $crawler->getStatusCode()); + $this->assertSame('http://localhost', $crawler->headers->get('Access-Control-Allow-Origin')); + } + + public function testPreflightDoesNotInvokeDownstreamMiddleware() + { + $this->app['router']->post('api/spy', ['uses' => fn () => 'OK']) + ->middleware(SpyMiddleware::class); + + $crawler = $this->call('OPTIONS', 'api/spy', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $crawler->getStatusCode()); + $this->assertSame('http://localhost', $crawler->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(0, SpyMiddleware::$invocations, 'Downstream route middleware must not run on preflight requests.'); + } + + public function testPreflightPassesThroughThrottleMiddleware() + { + RateLimiter::for('cors-preflight', fn () => Limit::perMinute(1)); + + $this->app['router']->post('api/throttled', ['uses' => fn () => 'OK']) + ->middleware(ThrottleRequests::class.':cors-preflight'); + + $first = $this->call('OPTIONS', 'api/throttled', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $first->getStatusCode()); + $this->assertSame('http://localhost', $first->headers->get('Access-Control-Allow-Origin')); + + $second = $this->call('OPTIONS', 'api/throttled', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $second->getStatusCode()); + $this->assertSame('http://localhost', $second->headers->get('Access-Control-Allow-Origin')); + } + + public function testNonCorsOptionsReturnsAllowHeader() + { + $crawler = $this->call('OPTIONS', 'api/ping'); + + $this->assertEquals(200, $crawler->getStatusCode()); + $this->assertSame('POST,PUT', $crawler->headers->get('Allow')); + } + protected function addWebRoutes(Router $router) { $router->post('web/ping', [ @@ -283,5 +358,22 @@ protected function addApiRoutes(Router $router) return 'ok'; }, ]); + + $router->post('api/protected', [ + 'middleware' => Authenticate::class.':web', + 'uses' => fn () => 'PROTECTED', + ]); + } +} + +class SpyMiddleware +{ + public static int $invocations = 0; + + public function handle($request, \Closure $next) + { + self::$invocations++; + + return $next($request); } } diff --git a/tests/Integration/Http/Middleware/RouteCorsTest.php b/tests/Integration/Http/Middleware/RouteCorsTest.php index 408717cd1f13..6048fc062046 100644 --- a/tests/Integration/Http/Middleware/RouteCorsTest.php +++ b/tests/Integration/Http/Middleware/RouteCorsTest.php @@ -2,11 +2,15 @@ namespace Illuminate\Tests\Integration\Http\Middleware; +use Illuminate\Auth\Middleware\Authenticate; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Http\Kernel; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\HandleRouteCors; use Illuminate\Routing\Attributes\Cors; +use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\RateLimiter; use Orchestra\Testbench\TestCase; class RouteCorsTest extends TestCase @@ -25,6 +29,11 @@ protected function defineEnvironment($app) $app['config']['app.key'] = 'base64:'.base64_encode(str_repeat('a', 32)); + $app['config']->set('auth.defaults.guard', 'web'); + $app['config']->set('auth.guards.web', ['driver' => 'route-cors-null']); + + $app['auth']->viaRequest('route-cors-null', fn () => null); + $kernel = $app->make(Kernel::class); $kernel->prependMiddleware(HandleCors::class); $kernel->prependMiddlewareToGroup('web', HandleRouteCors::class); @@ -61,6 +70,18 @@ protected function defineRoutes($router) $router->get('api/attr-over-route', [ControllerWithClassCors::class, 'index']) ->cors(['origins' => ['https://route-loses.example.com']]); + + $router->post('api/protected-route-cors', ['uses' => fn () => 'PROTECTED']) + ->middleware(Authenticate::class.':web') + ->cors(['origins' => ['https://app.example.com']]); + + $router->prefix('api/grouped-auth')->cors([ + 'origins' => ['https://group.example.com'], + 'methods' => ['GET'], + ])->group(function (Router $router) { + $router->get('secret', ['uses' => fn () => 'SECRET']) + ->middleware(Authenticate::class.':web'); + }); }); $router->middleware('web')->group(function (Router $router) { @@ -232,6 +253,61 @@ public function testRouteCorsWithCredentials() 'HTTP_ORIGIN' => 'https://creds.example.com', ])->assertHeader('Access-Control-Allow-Credentials', 'true'); } + + public function testPreflightAgainstProtectedRouteReturnsRouteCorsHeaders() + { + $response = $this->call('OPTIONS', 'api/protected-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $response->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + + $this->assertSame(['https://app.example.com'], $response->headers->all('Access-Control-Allow-Origin')); + } + + public function testPreflightAgainstGroupProtectedRouteReturnsGroupCorsHeaders() + { + $this->call('OPTIONS', 'api/grouped-auth/secret', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testNonCorsOptionsStillReturnsAllowHeader() + { + $response = $this->call('OPTIONS', 'api/route-cors'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('GET,HEAD,POST', $response->headers->get('Allow')); + } + + public function testPreflightAgainstThrottledRouteReturnsRouteCorsHeaders() + { + RateLimiter::for('route-cors-preflight', fn () => Limit::perMinute(1)); + + $this->app['router']->middleware('api')->post('api/throttled-route-cors', ['uses' => fn () => 'OK']) + ->middleware(ThrottleRequests::class.':route-cors-preflight') + ->cors(['origins' => ['https://app.example.com']]); + + $first = $this->call('OPTIONS', 'api/throttled-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $first->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + + $second = $this->call('OPTIONS', 'api/throttled-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $second->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } } #[Cors(origins: ['https://class-attr.example.com'])] From 5bfbddf7f643b0bf3f3d8b83b26f3db7748a15bd Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 21 Apr 2026 14:49:57 +0000 Subject: [PATCH 4/5] Apply fixes from StyleCI --- src/Illuminate/Http/Middleware/HandleCors.php | 2 +- tests/Integration/Http/Middleware/HandleCorsTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Illuminate/Http/Middleware/HandleCors.php b/src/Illuminate/Http/Middleware/HandleCors.php index 5f39c84042b6..c2264eb2e163 100644 --- a/src/Illuminate/Http/Middleware/HandleCors.php +++ b/src/Illuminate/Http/Middleware/HandleCors.php @@ -100,7 +100,7 @@ protected function resolveOptionsForPreflight(Request $request): array $probe->setMethod($intendedMethod); $route = $this->container['router']->getRoutes()->match($probe); - } catch (NotFoundHttpException | MethodNotAllowedHttpException) { + } catch (NotFoundHttpException|MethodNotAllowedHttpException) { return $globalOptions; } diff --git a/tests/Integration/Http/Middleware/HandleCorsTest.php b/tests/Integration/Http/Middleware/HandleCorsTest.php index 6abd2580d133..d1b286a285c6 100644 --- a/tests/Integration/Http/Middleware/HandleCorsTest.php +++ b/tests/Integration/Http/Middleware/HandleCorsTest.php @@ -10,7 +10,6 @@ use Illuminate\Http\Request; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Router; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Orchestra\Testbench\TestCase; From 9ce5e1b641bdbefcc96fb1180a6419eca6f4b54f Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 21 Apr 2026 15:57:38 +0100 Subject: [PATCH 5/5] Narrow Cors::toArray return type to an array shape --- src/Illuminate/Routing/Attributes/Cors.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Routing/Attributes/Cors.php b/src/Illuminate/Routing/Attributes/Cors.php index 5356c6a59d41..5156b33cfdba 100644 --- a/src/Illuminate/Routing/Attributes/Cors.php +++ b/src/Illuminate/Routing/Attributes/Cors.php @@ -28,7 +28,14 @@ public function __construct( /** * Get the CORS options as an array, excluding null values. * - * @return array + * @return array{ + * origins?: array, + * methods?: array, + * headers?: array, + * exposed_headers?: array, + * max_age?: int, + * credentials?: bool, + * } */ public function toArray(): array {