CDN caching for a Laravel app: what Cloudflare may hold
Behind Cloudflare, a Laravel app splits into three cache layers: static assets at the edge for a year, public pages cached briefly with stale-while-revalidate, and session-bound responses never cached at all. Laravel's whole job is sending precise Cache-Control headers. The edge follows them mechanically, so imprecise headers cost you the hit rate exactly as written.
Which responses may the edge cache?#
| Layer | Holds | Typical directive | Set by |
|---|---|---|---|
| Browser | hashed assets, fonts | max-age=31536000, immutable | file-name hashing at build |
| CDN edge | public pages, cacheable GET endpoints | s-maxage=120, stale-while-revalidate=600 | app middleware |
| Application | rendered fragments, query results | Laravel cache or Redis, minutes to hours | code |
The edge layer is the interesting one because it is shared: one copy of your landing page serves every visitor. That is also why a single session cookie attached to that page quietly switches edge caching off for everyone.
Cloudflare's defaults surprise everyone once#
By default, per Cloudflare's own documentation, the edge caches only responses for static file extensions (css, js, images, fonts). HTML passes through uncached with a cf-cache-status: DYNAMIC header unless a Cache Rule says otherwise. Any response carrying a Set-Cookie header bypasses the cache. Laravel sends a session cookie to every new visitor by default, so the out-of-the-box behavior is: no HTML cached anywhere, and nothing in the dashboard telling you it could be different. Reading the cf-cache-status header per response is the honest audit.
The middleware that decides#
Two rules carry most of the weight: authenticated requests mean private, no-store, and a short allowlist of genuinely public routes gets edge directives:
class CacheHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->user()) {
$response->headers->set('Cache-Control', 'private, no-store');
} elseif (in_array($request->path(), ['/', 'blog', 'impressum'], true)) {
$response->headers->set(
'Cache-Control',
'public, s-maxage=120, stale-while-revalidate=600',
);
}
return $response;
}
}s-maxage controls the edge, max-age the browser, and stale-while-revalidate lets Cloudflare serve the last copy for up to ten minutes while fetching a fresh one in the background. A deploy therefore produces at most two minutes of staleness, which for most pages is the correct trade.
The session cookie deserves its own sentence: keep StartSession off routes that should be edge-cached, or the Set-Cookie bypass eats your hit rate page by page. Laravel lets you drop session middleware per route group, and marketing pages rarely need a session.
Purging on purpose#
Short TTLs make most purges unnecessary. When a page must update immediately, Cloudflare's purge API takes exact URLs (up to thirty per call) or the whole zone. Design invalidation before you need it: a deploy hook that purges the five URLs that actually change beats an emergency purge-everything that dumps your hit rate for the next hour.
Query strings are part of Cloudflare's cache key by default, so /blog?page=2 and /blog?utm_source=newsletter are separate cache entries. Normalize canonical URLs in the app or strip tracking parameters in a Cache Rule, or the edge fills with one-hit entries nobody ever serves twice.
What stays in the application layer#
Personalized fragments (carts, dashboards, anything per-user) belong in Redis with tags and TTLs. The edge handles shared pages, the app cache handles personalized ones, and neither should pretend to do the other's job: per-user content in a shared edge cache is not a performance feature but a data leak waiting for its first authenticated page to slip through.
Verify with headers, not with the dashboard alone: cf-cache-status should read HIT on public pages, DYNAMIC on authenticated ones, and never the reverse. A HIT on a logged-in response is not a performance win; it is an incident.