Skip to content

[13.x] Add static route cache support#60286

Draft
DGarbs51 wants to merge 2 commits into
13.xfrom
add-static-route-method-for-cache-control
Draft

[13.x] Add static route cache support#60286
DGarbs51 wants to merge 2 commits into
13.xfrom
add-static-route-method-for-cache-control

Conversation

@DGarbs51

@DGarbs51 DGarbs51 commented May 27, 2026

Copy link
Copy Markdown
Contributor

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:

  • skip configured stateful middleware for cacheable non-Inertia requests,
  • strip response cookies before the response is cached,
  • emit Cache-Control, optional CDN-Cache-Control, and merged Vary headers,
  • bypass static response mutation for X-Inertia requests, non-cacheable methods, non-cacheable statuses, and redirects.

The local target was confirmed as Laravel 13.x: composer.json aliases dev-master to 13.0.x-dev, and the local origin/HEAD points at origin/13.x.

Design

The route API stores static-cache metadata under the route action's static_cache key and attaches Illuminate\Routing\Middleware\CacheStaticResponse.

For request-side middleware stripping, this PR uses a narrow conditional pipeline approach instead of permanently calling withoutMiddleware() from Route::static(). This keeps the behavior request-aware:

  • normal cacheable static requests skip configured stateful middleware,
  • X-Inertia requests keep normal middleware so Inertia partial reloads remain dynamic,
  • non-cacheable methods keep normal middleware.

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-store cache headers. Livewire's back-button-cache middleware is the practical case this protects: a user-independent initial Livewire render can still be made cacheable by Route::static(), while Livewire update requests remain untouched.

Configuration

The default options live under a new top-level static key in config/cache.php:

  • ttl
  • browser_ttl
  • strip_cookies
  • strip_middleware
  • vary
  • cdn_cache_control

This lives in config/cache.php because the feature configures HTTP cache policy and CDN/browser cache behavior. It does not belong in config/session.php, and a new config/http.php would 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:

  • what Route::static() does and does not do,
  • routes that should not use it, such as authenticated or per-user content,
  • the Cloudflare Cache Rule requirement,
  • Inertia behavior,
  • Livewire compatibility for user-independent initial renders.

Compatibility Verification

Verified with the Herd test app against the Laravel 13.x PR branch:

  • PHP 8.4 + Inertia 2 + Livewire 3: passed
  • PHP 8.4 + Inertia 2 + Livewire 4: passed
  • PHP 8.4 + Inertia 3 + Livewire 3: passed
  • PHP 8.4 + Inertia 3 + Livewire 4: passed
  • PHP 8.5 + Inertia 2 + Livewire 3: passed
  • PHP 8.5 + Inertia 2 + Livewire 4: passed
  • PHP 8.5 + Inertia 3 + Livewire 3: passed
  • PHP 8.5 + Inertia 3 + Livewire 4: passed

Inertia 1 was not installable with Laravel 13.x because inertiajs/inertia-laravel 1.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.php
  • vendor/bin/phpunit tests/Routing/RoutingRouteTest.php tests/Http/Middleware/CacheTest.php
  • vendor/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

@DGarbs51 DGarbs51 changed the title Add static route cache support [13.x] Add static route cache support May 27, 2026
Comment thread config/cache.php
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,
],
'vary' => ['X-Inertia'],

@shaedrich shaedrich May 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could narrow this further down if you want:

Suggested change
* @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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, you could use the constants here:

Suggested change
! 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could attempt to narrow this down at least to this:

Suggested change
* @return array
* @return array<string, mixed>

* Merge the configured Vary headers into the response.
*
* @param \Symfony\Component\HttpFoundation\Response $response
* @param array $vary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can narrow this down:

Suggested change
* @param array $vary
* @param array<int, string> $vary

or

Suggested change
* @param array $vary
* @param string[] $vary

or

Suggested change
* @param array $vary
* @param list<string> $vary

*/
protected function setVary(Response $response, array $vary)
{
$headers = array_merge(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make sure, the type fits, you may do this:

Suggested change
$headers = array_merge(
if (! array_is_list($vary)) {
throw new InvalidArgumentException('$vary must be a list, associative array given');
}
$headers = array_merge(

Comment on lines +264 to +285
$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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume, this could be easier:

Suggested change
$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

Suggested change
$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();

@shaedrich

shaedrich commented May 27, 2026

Copy link
Copy Markdown
Contributor

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 Vary header values.

@jasonmccreary

jasonmccreary commented May 28, 2026

Copy link
Copy Markdown
Contributor

This would be a very sharp knife.

While I think page caching is great and would love to see more support within the framework, there are implications when caching a static copy of your rendered page.

From a community perspective, I worry modern devs are not familiar with page caching strategies. With this a dev could easily throw static on a route in the name of performance. Again, without knowing the implications. In addition, what are the downstream (community package) implications which could use (or don't expect) static routes.

From a technical perspective, simply removing "stateful middleware" and "stateful headers" doesn't mean a page is cacheable. Developers may still be using state to render the initial page. Now they have user specific data on a publicly cached page. Or they may set a cookie which now doesn't make it back in the response. Any misconfiguration could have massive implications.

For Fast Laravel, I created a new, separate middleware to cache pages. It silos routes which are static. This avoids any overlapping middleware (core or community). It also throws errors when you access stateful features (e.g. Session store not set). There's no "stateful headers" to remove because they can never be set. If they somehow are, then the response would not be cached (e.g. Cloudflare BYPASS).

Again, I'd love to see this in the framework. But feel this needs more guardrails to avoid "footguns" which would create a bad development experience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants