[13.x] Add static route cache support#60286
Conversation
| \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, | ||
| \Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class, | ||
| ], | ||
| 'vary' => ['X-Inertia'], |
There was a problem hiding this comment.
Isn't this too Inertia-specific for Laravel? What if Inertia isn't even used? What if something else is used? Laravel != Inertia
| * Handle an incoming request. | ||
| * | ||
| * @param \Illuminate\Http\Request $request | ||
| * @param \Closure $next |
There was a problem hiding this comment.
You could narrow this further down if you want:
| * @param \Closure $next | |
| * @param \Closure(\Illuminate\Http\Request): \Symfony\Component\HttpFoundation\Response $next |
| return $request->headers->has('X-Inertia') || | ||
| ! $request->isMethodCacheable() || | ||
| $response instanceof RedirectResponse || | ||
| ! in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410], true); |
There was a problem hiding this comment.
Technically, you could use the constants here:
| ! in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410], true); | |
| ! in_array($response->getStatusCode(), [ | |
| Response::HTTP_OK, | |
| Response::HTTP_NON_AUTHORITATIVE_INFORMATION, | |
| Response::HTTP_MULTIPLE_CHOICES, | |
| Response::HTTP_MOVED_PERMANENTLY, | |
| Response::HTTP_FOUND, | |
| Response::HTTP_NOT_FOUND, | |
| Response::HTTP_GONE, | |
| ], true); |
| * Resolve the options for the current request. | ||
| * | ||
| * @param \Illuminate\Http\Request $request | ||
| * @return array |
There was a problem hiding this comment.
We could attempt to narrow this down at least to this:
| * @return array | |
| * @return array<string, mixed> |
| * Merge the configured Vary headers into the response. | ||
| * | ||
| * @param \Symfony\Component\HttpFoundation\Response $response | ||
| * @param array $vary |
There was a problem hiding this comment.
We can narrow this down:
| * @param array $vary | |
| * @param array<int, string> $vary |
or
| * @param array $vary | |
| * @param string[] $vary |
or
| * @param array $vary | |
| * @param list<string> $vary |
| */ | ||
| protected function setVary(Response $response, array $vary) | ||
| { | ||
| $headers = array_merge( |
There was a problem hiding this comment.
To make sure, the type fits, you may do this:
| $headers = array_merge( | |
| if (! array_is_list($vary)) { | |
| throw new InvalidArgumentException('$vary must be a list, associative array given'); | |
| } | |
| $headers = array_merge( |
| $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; |
There was a problem hiding this comment.
I assume, this could be easier:
| $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; | |
| return array_keys(array_unique(array_filter( | |
| array_map(fn (string $header) => strtolower(trim($header)), array_combine($headers, $headers)), | |
| fn (string $header) => $header !== '', | |
| ))); |
or
| $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; | |
| return collect($headers) | |
| ->combine($headers) | |
| ->map(fn (string $header) => strtolower(trim($header))) | |
| ->filter(fn (string $header) => $header !== '') | |
| ->unique() | |
| ->keys() | |
| ->all(); |
|
Should there maybe some safeguards that make the cache "directive" (for the lack of a better word) fail in caches where caching will be impossible as you described in the docs? I assume, not all cases can be covered, but some surely. This would probably be dependent on the |
Summary
This PR adds a first-class
Route::static()method for routes whose HTML responses are safe to cache in shared/CDN caches.Static routes:
Cache-Control, optionalCDN-Cache-Control, and mergedVaryheaders,X-Inertiarequests, non-cacheable methods, non-cacheable statuses, and redirects.The local target was confirmed as Laravel 13.x:
composer.jsonaliasesdev-masterto13.0.x-dev, and the localorigin/HEADpoints atorigin/13.x.Design
The route API stores static-cache metadata under the route action's
static_cachekey and attachesIlluminate\Routing\Middleware\CacheStaticResponse.For request-side middleware stripping, this PR uses a narrow conditional pipeline approach instead of permanently calling
withoutMiddleware()fromRoute::static(). This keeps the behavior request-aware:X-Inertiarequests keep normal middleware so Inertia partial reloads remain dynamic,This preserves the useful parts of the existing middleware exclusion resolver, including middleware group expansion and subclass matching, without making the exclusion unconditional for every request to the route.
The HTTP kernel also reapplies the static response mutation after the full middleware stack has completed. This makes the final emitted headers authoritative when a later global middleware writes
private/no-storecache headers. Livewire's back-button-cache middleware is the practical case this protects: a user-independent initial Livewire render can still be made cacheable byRoute::static(), while Livewire update requests remain untouched.Configuration
The default options live under a new top-level
statickey inconfig/cache.php:ttlbrowser_ttlstrip_cookiesstrip_middlewarevarycdn_cache_controlThis lives in
config/cache.phpbecause the feature configures HTTP cache policy and CDN/browser cache behavior. It does not belong inconfig/session.php, and a newconfig/http.phpwould introduce broader new configuration surface for one cache-related feature. The middleware also keeps hardcoded defaults so the framework remains usable before the application skeleton receives the matching config block.Cloudflare / CDN caveat
These headers mark a response as suitable for shared caching, but Cloudflare and similar CDNs may still require a Cache Rule or equivalent edge configuration before HTML is cached. Cache invalidation and purging remain CDN-side concerns and are intentionally out of scope.
Related PRs
Docs
Docs PR: laravel/docs#11214
The docs PR covers:
Route::static()does and does not do,Compatibility Verification
Verified with the Herd test app against the Laravel 13.x PR branch:
Inertia 1 was not installable with Laravel 13.x because
inertiajs/inertia-laravel1.x only allows Laravel 8-12. Inertia 1 was also not installable on PHP 8.5 because it only allows PHP through 8.4.Tests
vendor/bin/phpunit tests/Routing/RouteStaticMethodTest.php tests/Http/Middleware/CacheStaticResponseTest.php tests/Integration/Routing/RouteStaticResponseTest.php tests/Foundation/Http/KernelTest.phpvendor/bin/phpunit tests/Routing/RoutingRouteTest.php tests/Http/Middleware/CacheTest.phpvendor/bin/pint config/cache.php src/Illuminate/Foundation/Http/Kernel.php src/Illuminate/Routing/Route.php src/Illuminate/Routing/Router.php src/Illuminate/Routing/Middleware/CacheStaticResponse.php tests/Foundation/Http/KernelTest.php tests/Routing/RouteStaticMethodTest.php tests/Http/Middleware/CacheStaticResponseTest.php tests/Integration/Routing/RouteStaticResponseTest.php