Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions config/cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
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

'cdn_cache_control' => true,
],

/*
|--------------------------------------------------------------------------
| Cache Key Prefix
Expand Down
26 changes: 26 additions & 0 deletions src/Illuminate/Foundation/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +105,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,
Expand Down Expand Up @@ -148,13 +152,35 @@ public function handle($request)
$response = $this->renderException($request, $e);
}

$response = $this->prepareStaticResponse($request, $response);

$this->app['events']->dispatch(
new RequestHandled($request, $response)
);

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.
*
Expand Down
287 changes: 287 additions & 0 deletions src/Illuminate/Routing/Middleware/CacheStaticResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
<?php

namespace Illuminate\Routing\Middleware;

use Closure;
use Illuminate\Container\Container;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Route;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;

class CacheStaticResponse
{
/**
* Get the default options for static route caching.
*
* @return array
*/
public static function defaultOptions()
{
return [
'ttl' => 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
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 \Symfony\Component\HttpFoundation\Response
*/
public function handle($request, Closure $next)
{
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;
}

$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']);

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

*/
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);
}
}

/**
* 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.
*
* @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
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

* @return void
*/
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(

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

}
}
Loading
Loading