From a94fa0e2e7afa062cc68e5434aec8787aea78157 Mon Sep 17 00:00:00 2001 From: Devon Garbalosa Date: Tue, 26 May 2026 23:01:11 -0400 Subject: [PATCH 1/2] Add static route cache support --- config/cache.php | 25 ++ src/Illuminate/Foundation/Http/Kernel.php | 1 + .../Middleware/CacheStaticResponse.php | 253 ++++++++++++++++++ src/Illuminate/Routing/Route.php | 30 +++ src/Illuminate/Routing/Router.php | 53 +++- tests/Foundation/Http/KernelTest.php | 3 + .../Middleware/CacheStaticResponseTest.php | 204 ++++++++++++++ .../Routing/RouteStaticResponseTest.php | 92 +++++++ tests/Routing/RouteStaticMethodTest.php | 189 +++++++++++++ 9 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 src/Illuminate/Routing/Middleware/CacheStaticResponse.php create mode 100644 tests/Http/Middleware/CacheStaticResponseTest.php create mode 100644 tests/Integration/Routing/RouteStaticResponseTest.php create mode 100644 tests/Routing/RouteStaticMethodTest.php diff --git a/config/cache.php b/config/cache.php index 923671e14e53..7b7d63c4404c 100644 --- a/config/cache.php +++ b/config/cache.php @@ -112,6 +112,31 @@ ], + /* + |-------------------------------------------------------------------------- + | Static Route Cache + |-------------------------------------------------------------------------- + | + | These options control the default cache policy for routes that are marked + | as static. Static routes are intended for public, CDN-cacheable responses + | and should not depend on session state, cookies, or per-user content. + | + */ + + 'static' => [ + 'ttl' => 3600, + 'browser_ttl' => 0, + 'strip_cookies' => null, + 'strip_middleware' => [ + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class, + ], + 'vary' => ['X-Inertia'], + 'cdn_cache_control' => true, + ], + /* |-------------------------------------------------------------------------- | Cache Key Prefix diff --git a/src/Illuminate/Foundation/Http/Kernel.php b/src/Illuminate/Foundation/Http/Kernel.php index 3760a4d8c4ba..cfcfb3d7b8e1 100644 --- a/src/Illuminate/Foundation/Http/Kernel.php +++ b/src/Illuminate/Foundation/Http/Kernel.php @@ -102,6 +102,7 @@ class Kernel implements KernelContract */ protected $middlewarePriority = [ \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + \Illuminate\Routing\Middleware\CacheStaticResponse::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, diff --git a/src/Illuminate/Routing/Middleware/CacheStaticResponse.php b/src/Illuminate/Routing/Middleware/CacheStaticResponse.php new file mode 100644 index 000000000000..5bf42f89d2e6 --- /dev/null +++ b/src/Illuminate/Routing/Middleware/CacheStaticResponse.php @@ -0,0 +1,253 @@ + 3600, + 'browser_ttl' => 0, + 'strip_cookies' => null, + 'strip_middleware' => [ + StartSession::class, + ShareErrorsFromSession::class, + AddQueuedCookiesToResponse::class, + PreventRequestForgery::class, + ], + 'vary' => ['X-Inertia'], + 'cdn_cache_control' => true, + ]; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return \Symfony\Component\HttpFoundation\Response + */ + public function handle($request, Closure $next) + { + $response = $next($request); + + if ($this->shouldBypass($request, $response)) { + return $response; + } + + $options = $this->resolveOptions($request); + + $this->stripCookies($response, $options['strip_cookies']); + $this->setCacheControl($response, (int) $options['ttl'], (int) $options['browser_ttl']); + $this->setCdnCacheControl($response, (int) $options['ttl'], (bool) $options['cdn_cache_control']); + $this->setVary($response, $options['vary']); + + return $response; + } + + /** + * Determine if the response should not be made cacheable. + * + * @param \Illuminate\Http\Request $request + * @param \Symfony\Component\HttpFoundation\Response $response + * @return bool + */ + protected function shouldBypass($request, Response $response) + { + return $request->headers->has('X-Inertia') || + ! $request->isMethodCacheable() || + $response instanceof RedirectResponse || + ! in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410], true); + } + + /** + * Resolve the options for the current request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function resolveOptions($request) + { + $defaults = static::defaultOptions(); + + $options = array_replace( + $defaults, + $this->configuredOptions(), + $this->routeOptions($request), + ); + + $options['ttl'] ??= $defaults['ttl']; + $options['browser_ttl'] ??= $defaults['browser_ttl']; + $options['strip_middleware'] ??= $defaults['strip_middleware']; + $options['vary'] ??= $defaults['vary']; + $options['cdn_cache_control'] ??= $defaults['cdn_cache_control']; + + return $options; + } + + /** + * Get the configured static cache options. + * + * @return array + */ + protected function configuredOptions() + { + $container = Container::getInstance(); + + if (! $container->bound('config')) { + return []; + } + + $config = $container->make('config')->get('cache.static', []); + + return is_array($config) ? $config : []; + } + + /** + * Get the route-specific static cache options. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function routeOptions($request) + { + $route = $request->route(); + + if (! $route instanceof Route) { + return []; + } + + $options = $route->getAction('static_cache') ?? []; + + return is_array($options) ? $options : []; + } + + /** + * Strip configured cookies from the response. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param array|null $cookies + * @return void + */ + protected function stripCookies(Response $response, ?array $cookies) + { + if (is_null($cookies)) { + $response->headers->remove('Set-Cookie'); + + return; + } + + foreach ($cookies as $cookie) { + $response->headers->removeCookie($cookie); + } + } + + /** + * Set the Cache-Control header for browser and shared caches. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param int $ttl + * @param int $browserTtl + * @return void + */ + protected function setCacheControl(Response $response, int $ttl, int $browserTtl) + { + $response->headers->set( + 'Cache-Control', + 'public, max-age='.$browserTtl.', s-maxage='.$ttl, + true + ); + } + + /** + * Set the CDN-Cache-Control header when enabled. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param int $ttl + * @param bool $enabled + * @return void + */ + protected function setCdnCacheControl(Response $response, int $ttl, bool $enabled) + { + if ($enabled) { + $response->headers->set('CDN-Cache-Control', 'public, max-age='.$ttl, true); + } + } + + /** + * Merge the configured Vary headers into the response. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param array $vary + * @return void + */ + protected function setVary(Response $response, array $vary) + { + $headers = array_merge( + $this->parseVaryHeader($response->headers->get('Vary')), + $vary, + ['X-Inertia'], + ); + + $response->headers->set('Vary', implode(', ', $this->uniqueVaryHeaders($headers)), true); + } + + /** + * Parse a Vary header into individual header names. + * + * @param string|null $header + * @return array + */ + protected function parseVaryHeader($header) + { + return is_null($header) ? [] : explode(',', $header); + } + + /** + * Deduplicate the given Vary header names. + * + * @param array $headers + * @return array + */ + protected function uniqueVaryHeaders(array $headers) + { + $seen = []; + $unique = []; + + foreach ($headers as $header) { + $header = trim($header); + + if ($header === '') { + continue; + } + + $key = strtolower($header); + $header = $key === 'x-inertia' ? 'X-Inertia' : $header; + + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $unique[] = $header; + } + + return $unique; + } +} diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index df460dc03545..668c03ab7c6b 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -16,6 +16,7 @@ use Illuminate\Routing\Matching\MethodValidator; use Illuminate\Routing\Matching\SchemeValidator; use Illuminate\Routing\Matching\UriValidator; +use Illuminate\Routing\Middleware\CacheStaticResponse; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -1090,6 +1091,35 @@ public function middleware($middleware = null) return $this; } + /** + * Specify that the route's response should be safe to cache by shared caches. + * + * @param int|null $ttl + * @param int|null $browserTtl + * @param array|null $stripCookies + * @param array|null $stripMiddleware + * @param array|null $vary + * @return $this + * + * @named-arguments-supported + */ + public function static(?int $ttl = null, ?int $browserTtl = null, ?array $stripCookies = null, ?array $stripMiddleware = null, ?array $vary = null): static + { + $options = array_filter([ + 'ttl' => $ttl, + 'browser_ttl' => $browserTtl, + 'strip_cookies' => $stripCookies, + 'strip_middleware' => $stripMiddleware, + 'vary' => $vary, + ], fn ($value) => ! is_null($value)); + + $this->action['static_cache'] = array_merge( + (array) ($this->action['static_cache'] ?? []), $options + ); + + return $this->middleware(CacheStaticResponse::class); + } + /** * Specify that the "Authorize" / "can" middleware should be applied to the route with the given options. * diff --git a/src/Illuminate/Routing/Router.php b/src/Illuminate/Routing/Router.php index 24ef495b398b..1cc3a2390651 100644 --- a/src/Illuminate/Routing/Router.php +++ b/src/Illuminate/Routing/Router.php @@ -19,6 +19,7 @@ use Illuminate\Routing\Events\ResponsePrepared; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Routing\Events\Routing; +use Illuminate\Routing\Middleware\CacheStaticResponse; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -813,7 +814,7 @@ protected function runRouteWithinStack(Route $route, Request $request) $shouldSkipMiddleware = $this->container->bound('middleware.disable') && $this->container->make('middleware.disable') === true; - $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route); + $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route, $request); return (new Pipeline($this->container)) ->send($request) @@ -827,11 +828,57 @@ protected function runRouteWithinStack(Route $route, Request $request) * Gather the middleware for the given route with resolved class names. * * @param \Illuminate\Routing\Route $route + * @param \Illuminate\Http\Request|null $request * @return array */ - public function gatherRouteMiddleware(Route $route) + public function gatherRouteMiddleware(Route $route, ?Request $request = null) { - return $this->resolveMiddleware($route->gatherMiddleware(), $route->excludedMiddleware()); + $excluded = $route->excludedMiddleware(); + + if ($this->shouldExcludeStaticRouteMiddleware($route, $request)) { + $excluded = array_merge($excluded, $this->staticRouteExcludedMiddleware($route)); + } + + return $this->resolveMiddleware($route->gatherMiddleware(), $excluded); + } + + /** + * Determine if static route middleware should be removed for this request. + * + * @param \Illuminate\Routing\Route $route + * @param \Illuminate\Http\Request|null $request + * @return bool + */ + protected function shouldExcludeStaticRouteMiddleware(Route $route, ?Request $request = null) + { + return ! is_null($request) && + array_key_exists('static_cache', $route->getAction()) && + ! $request->headers->has('X-Inertia') && + $request->isMethodCacheable(); + } + + /** + * Get the middleware that should be excluded for static routes. + * + * @param \Illuminate\Routing\Route $route + * @return array + */ + protected function staticRouteExcludedMiddleware(Route $route) + { + $defaults = CacheStaticResponse::defaultOptions(); + $routeOptions = $route->getAction('static_cache') ?? []; + + if (! is_array($routeOptions)) { + $routeOptions = []; + } + + $config = $this->container->bound('config') + ? $this->container->make('config')->get('cache.static', []) + : []; + + $options = array_replace($defaults, is_array($config) ? $config : [], $routeOptions); + + return Arr::wrap($options['strip_middleware'] ?? []); } /** diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 57e53cee3f4c..ab827cd810f7 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -33,6 +33,7 @@ public function testGetMiddlewarePriority() $this->assertEquals([ \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + \Illuminate\Routing\Middleware\CacheStaticResponse::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, @@ -60,6 +61,7 @@ public function testAddToMiddlewarePriorityAfter() $this->assertEquals([ \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + \Illuminate\Routing\Middleware\CacheStaticResponse::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, @@ -88,6 +90,7 @@ public function testAddToMiddlewarePriorityBefore() $this->assertEquals([ \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + \Illuminate\Routing\Middleware\CacheStaticResponse::class, \Illuminate\Routing\Middleware\ValidateSignature::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, diff --git a/tests/Http/Middleware/CacheStaticResponseTest.php b/tests/Http/Middleware/CacheStaticResponseTest.php new file mode 100644 index 000000000000..05d6f842c32d --- /dev/null +++ b/tests/Http/Middleware/CacheStaticResponseTest.php @@ -0,0 +1,204 @@ +container = new Container); + } + + protected function tearDown(): void + { + Container::setInstance(new Container); + + parent::tearDown(); + } + + public function testItStripsAllCookiesByDefault() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return $this->responseWithCookies(); + }); + + $this->assertSame([], $response->headers->getCookies()); + } + + public function testItStripsOnlyConfiguredCookies() + { + $response = (new CacheStaticResponse)->handle($this->request([ + 'strip_cookies' => ['XSRF-TOKEN'], + ]), function () { + return $this->responseWithCookies(); + }); + + $this->assertSame(['laravel_session'], $this->cookieNames($response)); + } + + public function testItSetsStaticCacheHeaders() + { + $response = (new CacheStaticResponse)->handle($this->request([ + 'ttl' => 600, + 'browser_ttl' => 60, + ]), function () { + return new Response('Laravel'); + }); + + $this->assertSame('max-age=60, public, s-maxage=600', $response->headers->get('Cache-Control')); + $this->assertSame('public, max-age=600', $response->headers->get('CDN-Cache-Control')); + } + + public function testItSetsZeroBrowserTtlByDefault() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return new Response('Laravel'); + }); + + $this->assertSame('max-age=0, public, s-maxage=3600', $response->headers->get('Cache-Control')); + } + + public function testItCanDisableCdnCacheControlHeader() + { + $response = (new CacheStaticResponse)->handle($this->request([ + 'cdn_cache_control' => false, + ]), function () { + return new Response('Laravel'); + }); + + $this->assertNull($response->headers->get('CDN-Cache-Control')); + } + + public function testItAlwaysAddsInertiaVaryHeader() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return new Response('Laravel'); + }); + + $this->assertSame('X-Inertia', $response->headers->get('Vary')); + } + + public function testItMergesVaryHeaders() + { + $response = (new CacheStaticResponse)->handle($this->request([ + 'vary' => ['Accept-Language', 'x-inertia'], + ]), function () { + return new Response('Laravel', 200, ['Vary' => 'Accept-Encoding']); + }); + + $this->assertSame('Accept-Encoding, Accept-Language, X-Inertia', $response->headers->get('Vary')); + } + + public function testItDoesNotAddStaticHeadersForInertiaRequests() + { + $request = $this->request(); + $request->headers->set('X-Inertia', 'true'); + + $response = (new CacheStaticResponse)->handle($request, function () { + return $this->responseWithCookies(); + }); + + $this->assertNull($response->headers->get('CDN-Cache-Control')); + $this->assertSame(['laravel_session', 'XSRF-TOKEN'], $this->cookieNames($response)); + } + + public function testItDoesNotAddStaticHeadersForUncacheableMethods() + { + $response = (new CacheStaticResponse)->handle($this->request(method: 'POST'), function () { + return $this->responseWithCookies(); + }); + + $this->assertNull($response->headers->get('CDN-Cache-Control')); + $this->assertSame(['laravel_session', 'XSRF-TOKEN'], $this->cookieNames($response)); + } + + public function testItDoesNotAddStaticHeadersForUncacheableStatuses() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return $this->responseWithCookies(500); + }); + + $this->assertNull($response->headers->get('CDN-Cache-Control')); + $this->assertSame(['laravel_session', 'XSRF-TOKEN'], $this->cookieNames($response)); + } + + public function testItDoesNotAddStaticHeadersForRedirectResponses() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return new RedirectResponse('/login'); + }); + + $this->assertNull($response->headers->get('CDN-Cache-Control')); + } + + public function testItOverwritesPrivateCacheControlDirective() + { + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return new Response('Laravel', 200, ['Cache-Control' => 'private']); + }); + + $this->assertStringNotContainsString('private', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=0, public, s-maxage=3600', $response->headers->get('Cache-Control')); + } + + public function testItUsesConfiguredDefaults() + { + $this->container->instance('config', new Repository([ + 'cache' => [ + 'static' => [ + 'ttl' => 120, + 'browser_ttl' => 10, + 'vary' => ['Accept-Encoding'], + ], + ], + ])); + + $response = (new CacheStaticResponse)->handle($this->request(), function () { + return new Response('Laravel'); + }); + + $this->assertSame('max-age=10, public, s-maxage=120', $response->headers->get('Cache-Control')); + $this->assertSame('Accept-Encoding, X-Inertia', $response->headers->get('Vary')); + } + + protected function request(array $options = [], string $method = 'GET') + { + $request = Request::create('/', $method); + + $route = new Route([$method], '/', fn () => 'Laravel'); + $route->setAction(array_merge($route->getAction(), ['static_cache' => $options])); + + $request->setRouteResolver(fn () => $route); + + return $request; + } + + protected function responseWithCookies(int $status = 200) + { + $response = new Response('Laravel', $status); + + $response->headers->setCookie(Cookie::create('laravel_session', 'session')); + $response->headers->setCookie(Cookie::create('XSRF-TOKEN', 'token')); + + return $response; + } + + protected function cookieNames(Response $response) + { + return array_map(fn ($cookie) => $cookie->getName(), $response->headers->getCookies()); + } +} diff --git a/tests/Integration/Routing/RouteStaticResponseTest.php b/tests/Integration/Routing/RouteStaticResponseTest.php new file mode 100644 index 000000000000..202db6180287 --- /dev/null +++ b/tests/Integration/Routing/RouteStaticResponseTest.php @@ -0,0 +1,92 @@ +get('static-page'); + + $response->assertOk(); + $response->assertHeader('Cache-Control', 'max-age=0, public, s-maxage=3600'); + $response->assertHeader('CDN-Cache-Control', 'public, max-age=3600'); + $response->assertHeader('Vary', 'Accept-Encoding, X-Inertia'); + + $this->assertFalse(RouteStaticResponseTestSessionHandler::$written); + $this->assertSame([], $response->headers->getCookies()); + } + + public function testWebRouteWithoutStaticRunsSessionMiddleware() + { + $response = $this->get('dynamic-page'); + + $response->assertOk(); + + $this->assertTrue(RouteStaticResponseTestSessionHandler::$written); + $this->assertNotSame([], $response->headers->getCookies()); + } + + public function testInertiaRequestBypassesStaticHeaderMutation() + { + $response = $this->get('static-page', ['X-Inertia' => 'true']); + + $response->assertOk(); + $response->assertHeaderMissing('CDN-Cache-Control'); + $response->assertHeader('Vary', 'Accept-Encoding'); + + $this->assertTrue(RouteStaticResponseTestSessionHandler::$written); + $this->assertContains('route_cookie', array_map( + fn ($cookie) => $cookie->getName(), + $response->headers->getCookies(), + )); + } + + protected function defineEnvironment($app) + { + $app['config']->set('app.key', Str::random(32)); + $app['config']->set('session.driver', 'static-route-test'); + $app['config']->set('session.expire_on_close', true); + + Session::extend('static-route-test', fn () => new RouteStaticResponseTestSessionHandler); + } + + protected function defineRoutes($router) + { + Route::get('static-page', function () { + $response = new Response('static', 200, ['Vary' => 'Accept-Encoding']); + $response->headers->setCookie(Cookie::create('route_cookie', 'value')); + + return $response; + })->middleware('web')->static(); + + Route::get('dynamic-page', fn () => 'dynamic')->middleware('web'); + } +} + +class RouteStaticResponseTestSessionHandler extends NullSessionHandler +{ + public static bool $written = false; + + public function write($sessionId, $data): bool + { + static::$written = true; + + return true; + } +} diff --git a/tests/Routing/RouteStaticMethodTest.php b/tests/Routing/RouteStaticMethodTest.php new file mode 100644 index 000000000000..58e53c4e3bac --- /dev/null +++ b/tests/Routing/RouteStaticMethodTest.php @@ -0,0 +1,189 @@ +container = new Container); + + $this->container->instance('config', new Repository); + + $this->router = new Router(new Dispatcher($this->container), $this->container); + + $this->container->instance(Registrar::class, $this->router); + $this->container->bind(ControllerDispatcherContract::class, fn ($app) => new ControllerDispatcher($app)); + $this->container->bind(CallableDispatcherContract::class, fn ($app) => new CallableDispatcher($app)); + } + + protected function tearDown(): void + { + Container::setInstance(new Container); + + parent::tearDown(); + } + + public function testStaticMethodAddsCacheMiddlewareAndStoresRouteOptions() + { + $route = $this->router->get('about', fn () => 'about')->static( + ttl: 600, + browserTtl: 60, + stripCookies: ['XSRF-TOKEN'], + vary: ['Accept-Encoding'], + ); + + $this->assertContains(CacheStaticResponse::class, $route->middleware()); + $this->assertSame([ + 'ttl' => 600, + 'browser_ttl' => 60, + 'strip_cookies' => ['XSRF-TOKEN'], + 'vary' => ['Accept-Encoding'], + ], $route->getAction('static_cache')); + } + + public function testStaticMethodDoesNotSetRouteLevelMiddlewareExclusions() + { + $route = $this->router->get('about', fn () => 'about')->static(); + + $this->assertSame([], $route->excludedMiddleware()); + } + + public function testStaticMethodExcludesConfiguredMiddlewareForCacheableRequests() + { + $this->container->make('config')->set('cache.static.strip_middleware', [ + RouteStaticConfiguredMiddleware::class, + ]); + + $route = $this->router->get('about', [ + 'middleware' => [RouteStaticConfiguredMiddleware::class, RouteStaticUnrelatedMiddleware::class], + fn () => 'about', + ])->static(); + + $middleware = $this->router->gatherRouteMiddleware($route, Request::create('about', 'GET')); + + $this->assertNotContains(RouteStaticConfiguredMiddleware::class, $middleware); + $this->assertContains(RouteStaticUnrelatedMiddleware::class, $middleware); + } + + public function testCustomStripMiddlewareOverridesConfiguredMiddleware() + { + $this->container->make('config')->set('cache.static.strip_middleware', [ + RouteStaticConfiguredMiddleware::class, + ]); + + $route = $this->router->get('about', [ + 'middleware' => [ + RouteStaticConfiguredMiddleware::class, + RouteStaticCustomMiddleware::class, + RouteStaticUnrelatedMiddleware::class, + ], + fn () => 'about', + ])->static( + stripMiddleware: [RouteStaticCustomMiddleware::class], + ); + + $this->assertSame([RouteStaticCustomMiddleware::class], $route->getAction('static_cache')['strip_middleware']); + + $middleware = $this->router->gatherRouteMiddleware($route, Request::create('about', 'GET')); + + $this->assertContains(RouteStaticConfiguredMiddleware::class, $middleware); + $this->assertNotContains(RouteStaticCustomMiddleware::class, $middleware); + $this->assertContains(RouteStaticUnrelatedMiddleware::class, $middleware); + } + + public function testStaticMethodExcludesMiddlewareExpandedFromRouteGroups() + { + $this->router->middlewareGroup('web', [ + RouteStaticStartSessionMiddleware::class, + RouteStaticUnrelatedMiddleware::class, + ]); + + $route = $this->router->get('about', [ + 'middleware' => 'web', + fn () => 'about', + ])->static(); + + $middleware = $this->router->gatherRouteMiddleware($route, Request::create('about', 'GET')); + + $this->assertNotContains(RouteStaticStartSessionMiddleware::class, $middleware); + $this->assertContains(RouteStaticUnrelatedMiddleware::class, $middleware); + $this->assertContains(CacheStaticResponse::class, $middleware); + } + + public function testStaticMethodKeepsMiddlewareForInertiaRequests() + { + $this->router->middlewareGroup('web', [ + RouteStaticStartSessionMiddleware::class, + RouteStaticUnrelatedMiddleware::class, + ]); + + $route = $this->router->get('about', [ + 'middleware' => 'web', + fn () => 'about', + ])->static(); + + $request = Request::create('about', 'GET'); + $request->headers->set('X-Inertia', 'true'); + + $middleware = $this->router->gatherRouteMiddleware($route, $request); + + $this->assertContains(RouteStaticStartSessionMiddleware::class, $middleware); + $this->assertContains(RouteStaticUnrelatedMiddleware::class, $middleware); + $this->assertContains(CacheStaticResponse::class, $middleware); + } + + public function testStaticMethodKeepsMiddlewareForUncacheableRequests() + { + $route = $this->router->post('about', [ + 'middleware' => [RouteStaticStartSessionMiddleware::class, RouteStaticUnrelatedMiddleware::class], + fn () => 'about', + ])->static(); + + $middleware = $this->router->gatherRouteMiddleware($route, Request::create('about', 'POST')); + + $this->assertContains(RouteStaticStartSessionMiddleware::class, $middleware); + $this->assertContains(RouteStaticUnrelatedMiddleware::class, $middleware); + $this->assertContains(CacheStaticResponse::class, $middleware); + } +} + +class RouteStaticConfiguredMiddleware +{ + // +} + +class RouteStaticCustomMiddleware +{ + // +} + +class RouteStaticStartSessionMiddleware extends StartSession +{ + // +} + +class RouteStaticUnrelatedMiddleware +{ + // +} From 3959af2639eb68379375a38b11ce340b8e188e3d Mon Sep 17 00:00:00 2001 From: Devon Garbalosa Date: Tue, 26 May 2026 23:41:20 -0400 Subject: [PATCH 2/2] Harden static route cache headers --- src/Illuminate/Foundation/Http/Kernel.php | 25 ++++++++++++ .../Middleware/CacheStaticResponse.php | 36 ++++++++++++++++- src/Illuminate/Routing/Router.php | 2 +- .../Middleware/CacheStaticResponseTest.php | 8 +++- .../Routing/RouteStaticResponseTest.php | 40 +++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Foundation/Http/Kernel.php b/src/Illuminate/Foundation/Http/Kernel.php index cfcfb3d7b8e1..ef9856e2568e 100644 --- a/src/Illuminate/Foundation/Http/Kernel.php +++ b/src/Illuminate/Foundation/Http/Kernel.php @@ -9,12 +9,15 @@ use Illuminate\Contracts\Http\Kernel as KernelContract; use Illuminate\Foundation\Events\Terminating; use Illuminate\Foundation\Http\Events\RequestHandled; +use Illuminate\Routing\Middleware\CacheStaticResponse; use Illuminate\Routing\Pipeline; +use Illuminate\Routing\Route; use Illuminate\Routing\Router; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Request; use Illuminate\Support\InteractsWithTime; use InvalidArgumentException; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Throwable; class Kernel implements KernelContract @@ -149,6 +152,8 @@ public function handle($request) $response = $this->renderException($request, $e); } + $response = $this->prepareStaticResponse($request, $response); + $this->app['events']->dispatch( new RequestHandled($request, $response) ); @@ -156,6 +161,26 @@ public function handle($request) return $response; } + /** + * Apply static route caching after the full middleware stack has completed. + * + * @param \Illuminate\Http\Request $request + * @param \Symfony\Component\HttpFoundation\Response $response + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function prepareStaticResponse($request, $response) + { + $route = $request->route(); + + if ($response instanceof SymfonyResponse && + $route instanceof Route && + CacheStaticResponse::routeIsStatic($route)) { + return $this->app->make(CacheStaticResponse::class)->cache($request, $response); + } + + return $response; + } + /** * Send the given request through the middleware / router. * diff --git a/src/Illuminate/Routing/Middleware/CacheStaticResponse.php b/src/Illuminate/Routing/Middleware/CacheStaticResponse.php index 5bf42f89d2e6..91c1553ca310 100644 --- a/src/Illuminate/Routing/Middleware/CacheStaticResponse.php +++ b/src/Illuminate/Routing/Middleware/CacheStaticResponse.php @@ -45,8 +45,18 @@ public static function defaultOptions() */ public function handle($request, Closure $next) { - $response = $next($request); + return $this->cache($request, $next($request)); + } + /** + * Apply static route caching to the given response. + * + * @param \Illuminate\Http\Request $request + * @param \Symfony\Component\HttpFoundation\Response $response + * @return \Symfony\Component\HttpFoundation\Response + */ + public function cache($request, Response $response) + { if ($this->shouldBypass($request, $response)) { return $response; } @@ -54,6 +64,7 @@ public function handle($request, Closure $next) $options = $this->resolveOptions($request); $this->stripCookies($response, $options['strip_cookies']); + $this->removeNoCacheHeaders($response); $this->setCacheControl($response, (int) $options['ttl'], (int) $options['browser_ttl']); $this->setCdnCacheControl($response, (int) $options['ttl'], (bool) $options['cdn_cache_control']); $this->setVary($response, $options['vary']); @@ -61,6 +72,17 @@ public function handle($request, Closure $next) return $response; } + /** + * Determine if the given route has static caching enabled. + * + * @param \Illuminate\Routing\Route $route + * @return bool + */ + public static function routeIsStatic(Route $route) + { + return array_key_exists('static_cache', $route->getAction()); + } + /** * Determine if the response should not be made cacheable. * @@ -158,6 +180,18 @@ protected function stripCookies(Response $response, ?array $cookies) } } + /** + * Remove legacy no-cache headers from the response. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @return void + */ + protected function removeNoCacheHeaders(Response $response) + { + $response->headers->remove('Pragma'); + $response->headers->remove('Expires'); + } + /** * Set the Cache-Control header for browser and shared caches. * diff --git a/src/Illuminate/Routing/Router.php b/src/Illuminate/Routing/Router.php index 1cc3a2390651..33894d1f99c9 100644 --- a/src/Illuminate/Routing/Router.php +++ b/src/Illuminate/Routing/Router.php @@ -852,7 +852,7 @@ public function gatherRouteMiddleware(Route $route, ?Request $request = null) protected function shouldExcludeStaticRouteMiddleware(Route $route, ?Request $request = null) { return ! is_null($request) && - array_key_exists('static_cache', $route->getAction()) && + CacheStaticResponse::routeIsStatic($route) && ! $request->headers->has('X-Inertia') && $request->isMethodCacheable(); } diff --git a/tests/Http/Middleware/CacheStaticResponseTest.php b/tests/Http/Middleware/CacheStaticResponseTest.php index 05d6f842c32d..632e98476a0e 100644 --- a/tests/Http/Middleware/CacheStaticResponseTest.php +++ b/tests/Http/Middleware/CacheStaticResponseTest.php @@ -148,11 +148,17 @@ public function testItDoesNotAddStaticHeadersForRedirectResponses() public function testItOverwritesPrivateCacheControlDirective() { $response = (new CacheStaticResponse)->handle($this->request(), function () { - return new Response('Laravel', 200, ['Cache-Control' => 'private']); + return new Response('Laravel', 200, [ + 'Cache-Control' => 'private', + 'Expires' => 'Fri, 01 Jan 1990 00:00:00 GMT', + 'Pragma' => 'no-cache', + ]); }); $this->assertStringNotContainsString('private', $response->headers->get('Cache-Control')); $this->assertSame('max-age=0, public, s-maxage=3600', $response->headers->get('Cache-Control')); + $this->assertNull($response->headers->get('Expires')); + $this->assertNull($response->headers->get('Pragma')); } public function testItUsesConfiguredDefaults() diff --git a/tests/Integration/Routing/RouteStaticResponseTest.php b/tests/Integration/Routing/RouteStaticResponseTest.php index 202db6180287..8d606281d16b 100644 --- a/tests/Integration/Routing/RouteStaticResponseTest.php +++ b/tests/Integration/Routing/RouteStaticResponseTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Routing; +use Closure; use Illuminate\Http\Response; use Illuminate\Session\NullSessionHandler; use Illuminate\Support\Facades\Route; @@ -57,6 +58,24 @@ public function testInertiaRequestBypassesStaticHeaderMutation() )); } + public function testStaticRouteWinsOverLaterGlobalNoCacheMiddleware() + { + $this->app->make(\Illuminate\Contracts\Http\Kernel::class) + ->pushMiddleware(RouteStaticResponseTestNoStoreMiddleware::class); + + $response = $this->get('static-page-with-late-no-store'); + + $response->assertOk(); + $response->assertHeader('Cache-Control', 'max-age=0, public, s-maxage=3600'); + $response->assertHeader('CDN-Cache-Control', 'public, max-age=3600'); + $response->assertHeaderMissing('Pragma'); + $response->assertHeaderMissing('Expires'); + + $this->assertStringNotContainsString('private', $response->headers->get('Cache-Control')); + $this->assertStringNotContainsString('no-store', $response->headers->get('Cache-Control')); + $this->assertSame([], $response->headers->getCookies()); + } + protected function defineEnvironment($app) { $app['config']->set('app.key', Str::random(32)); @@ -76,6 +95,27 @@ protected function defineRoutes($router) })->middleware('web')->static(); Route::get('dynamic-page', fn () => 'dynamic')->middleware('web'); + + Route::get('static-page-with-late-no-store', fn () => 'static') + ->middleware('web') + ->static(); + } +} + +class RouteStaticResponseTestNoStoreMiddleware +{ + public function handle($request, Closure $next) + { + $response = $next($request); + + if ($request->path() === 'static-page-with-late-no-store') { + $response->headers->set('Cache-Control', 'no-cache, must-revalidate, no-store, max-age=0, private'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', 'Fri, 01 Jan 1990 00:00:00 GMT'); + $response->headers->setCookie(Cookie::create('late_cookie', 'value')); + } + + return $response; } }