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
Copy link
Copy Markdown
Contributor

@DGarbs51 DGarbs51 commented May 27, 2026

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'],
Copy link
Copy Markdown
Contributor

@shaedrich shaedrich May 27, 2026

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
Copy link
Copy Markdown
Contributor

shaedrich commented May 27, 2026

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.

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.

2 participants