Put a CDN in front of a fresh Laravel app and the hit rate for HTML will be zero. Nothing is broken. The framework is being careful on purpose, in two separate places, and both have to be dealt with before any shared cache will help.
I read the current framework source and ran live header checks on two well-known Laravel sites today. First, where the defaults come from.
The header comes from Symfony
Laravel's Response extends Symfony's, and Symfony's ResponseHeaderBag fills in Cache-Control whenever you haven't set one. From computeCacheControlValue():
if (!$this->cacheControl) {
if ($this->has('Last-Modified') || $this->has('Expires')) {
return 'private, must-revalidate';
}
// conservative by default
return 'no-cache, private';
}
So a plain return view('home') leaves with Cache-Control: no-cache, private. private keeps it out of every shared cache, and no-cache makes the browser revalidate before reuse. If you set directives but neither public nor private, Symfony appends private unless s-maxage is present. That's a sensible default for a framework that can't know whether a page is personal. It just means caching is opt-in, route by route.
The cookie comes from the web group
Every route in routes/web.php runs inside the web middleware group. In Laravel 12 and 13 that's EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, the CSRF middleware (ValidateCsrfToken in 12, PreventRequestForgery in 13) and SubstituteBindings.
Two of those set cookies on every response, including anonymous GETs:
-
StartSessionadds the session cookie. Its name comes fromAPP_NAME:laravel_sessionin the 10.x and 11.x skeletons,laravel-sessionin the current 12.x and 13.x ones. - The CSRF middleware adds
XSRF-TOKEN, which the docs describe as "a developer convenience" for Axios and Angular.
A Set-Cookie header doesn't stop caching by itself. RFC 9111 says it plainly: a cacheable response with Set-Cookie "can be (and often is) used to satisfy subsequent requests". That's the dangerous direction: a cache that stores the response hands one visitor's session cookie to everyone else. Most CDNs are cautious instead. Cloudflare's default behaviour page says it does not cache a resource when "the Set-Cookie header exists". Either way, a page that sets a session cookie is a page no CDN should share.
The built-in fix: cache.headers
Laravel ships a cache.headers middleware (SetCacheHeaders). Directives are snake case and separated by semicolons:
Route::middleware('cache.headers:public;max_age=0;s_maxage=600;etag')
->group(function () {
Route::get('/pricing', PricingController::class);
});
The options go straight to Symfony's Response::setCache(), so s_maxage, stale_while_revalidate, stale_if_error, immutable and last_modified all work. etag hashes the body with xxh128, and the middleware then calls isNotModified(), so a matching If-None-Match gets a 304 with no body.
It has limits worth knowing:
- It only touches GET and HEAD, successful responses, and responses with a body. Errors keep the default header.
-
In Laravel 12 and earlier it skips HEAD requests. By the time route middleware sees the response, Symfony's
prepare()has emptied the body for HEAD, and the empty-body check returns early. The 13.x source adds an explicit HEAD exception. On older versions,curl -Ican show youno-cache, privatewhile real GETs get your header. -
It never looks at cookies. And it couldn't see the session cookie anyway: group middleware wraps route middleware, so
StartSessionattaches its cookie on the way out, aftercache.headershas already setpublic. Inside thewebgroup you getpublic, s-maxage=600and a session cookie on the same response.
Live check 1: laravel.com
The header check, trimmed to the relevant headers:
cache-control: public, s-maxage=86400, stale-while-revalidate=300
cf-cache-status: DYNAMIC
laravel-cloud-cache: DYNAMIC
x-powered-by: Statamic
set-cookie: XSRF-TOKEN=…; expires=Tue, 03 Nov 2026 15:12:02 GMT; Max-Age=7200; path=/; secure; samesite=lax
set-cookie: laravelcom_session=…; expires=Tue, 03 Nov 2026 15:12:02 GMT; Max-Age=7200; path=/; secure; httponly; samesite=lax
There was also a third encrypted cookie with a random 40-character name, plus Cloudflare's __cf_bm. Two curl -sI requests right after gave the same cache-control, cf-cache-status: DYNAMIC and laravel-cloud-cache: DYNAMIC, with fresh values in all the cookies each time.
This is limit 3 in the wild. Someone opted the homepage into a one-day shared cache (s-maxage=86400), and the web group still attached XSRF-TOKEN and a session cookie named after the app (laravelcom_session, the slug of "laravel.com" plus _session). Both cache layers answered DYNAMIC, which Cloudflare's docs define as "not eligible for cache at request time": HTML isn't cached by default and no rule opts this page in. Given the cookies, that's the safe outcome. But the s-maxage buys nothing, and every request goes to the origin. The check graded it D, mostly for the Set-Cookie on a publicly cacheable response, plus no ETag or Last-Modified.
One more detail: Cloudflare's docs say s-maxage implies proxy-revalidate, which disables stale-while-revalidate there. If you sit behind Cloudflare, don't count on that combination. Put the stale window in a CDN-Cache-Control header instead (for example max-age=600, stale-while-revalidate=60): Cloudflare uses it in place of Cache-Control for its own caching decisions.
Live check 2: laravel-news.com
The header check (a GET):
cache-control: max-age=600, public, s-maxage=86400
last-modified: Tue, 03 Nov 2026 08:12:04 GMT
cf-cache-status: BYPASS
laravel-cloud-cache: DYNAMIC
set-cookie: __cf_bm=…; HttpOnly; SameSite=None; Secure; Path=/; Domain=laravel-news.com; Expires=Tue, 03 Nov 2026 13:42:04 GMT
This one did the cookie part right. No session cookie, no XSRF-TOKEN, only Cloudflare's bot cookie, so the homepage is served without a session. It got a C.
Then the two curl -sI requests disagreed with each other:
cache-control: max-age=600, public, s-maxage=86400
cf-cache-status: BYPASS
cache-control: no-cache, private
cf-cache-status: BYPASS
I sent six more HEAD requests and all six got no-cache, private. Four GETs all got max-age=600, public, s-maxage=86400. HEAD and GET are getting different policies. I can't see their code, but it's exactly what limit 2 produces, and it's a good reason not to trust curl -I alone on a Laravel site.
Two smaller things. last-modified was exactly five hours behind the Date header on every request and changed every second, so it works as a validator for nothing. And Cloudflare said BYPASS on every request. Its docs are specific about that status: the request was eligible (so a Cache Rule covers this HTML), but the origin response was not cacheable. Common reasons listed are private or no-store, a Set-Cookie from the origin, or a CDN-Cache-Control header. The GETs I saw carried public and only Cloudflare's own cookie, so whatever disqualifies them isn't visible from outside. It's still the most useful status to see: the edge wants to cache the page, and something at the origin is saying no.
The fix: a route group without a session
Public pages that look the same for every anonymous visitor shouldn't run StartSession at all. In Laravel 11 and later, register them in their own file with then: in bootstrap/app.php:
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Support\Facades\Route;
->withRouting(
web: __DIR__.'/../routes/web.php',
then: function () {
Route::middleware([
SubstituteBindings::class,
'cache.headers:public;max_age=0;s_maxage=600;etag',
])->group(base_path('routes/public.php'));
},
)
No session means no session cookie, no XSRF-TOKEN, and no @csrf forms on those pages, so keep forms and anything that reads auth() in web.php. max_age=0 keeps browsers coming back so a deploy shows up quickly, while s-maxage lets the CDN absorb the traffic.
For pages that do need a session, be explicit:
return response()->view('dashboard')
->header('Cache-Control', 'private, no-cache');
It's what Symfony sends anyway, but writing it down stops the next person from wrapping the route in cache.headers:public.
On Cloudflare, origin headers alone aren't enough for HTML. It doesn't cache HTML by default, so you also need a Cache Rule that makes those paths eligible.
Testing it
Send real GETs, twice:
curl -s -D - -o /dev/null https://example.com/pricing | grep -iE 'cache-control|set-cookie|cf-cache-status|age'
On a cacheable page you want public with an s-maxage, no set-cookie from your app, and the CDN status going from MISS to HIT with age climbing on the second request. Then repeat with a signed-in cookie and check that you get private.
The checks above came from nocache.dev, which I built. There's also a Laravel package (10 to 13) that covers both halves: composer require nocache-dev/laravel adds a nocache.headers:900 middleware for a session-free group like the one above. Anonymous GETs get public, max-age=0, s-maxage=900, plus CDN-Cache-Control: max-age=900, stale-while-revalidate=60 so Cloudflare can still serve stale while it revalidates. Signed-in users, requests that started a session, and responses that set a cookie get private, no-store, even if the route was left in the web group. php artisan nocache:check https://example.com --min-grade=B fails a CI step when a page's grade drops below B.
Top comments (1)
The key fix is separating truly public pages from Laravel’s session middleware, not merely adding cache.headers. Also, testing real GET requests instead of relying on curl -I is an important detail.