Skip to content
All articles

CDN caching for a Laravel app: what Cloudflare may hold

Sep 16, 2026 · DevOps · 3 min

·By Dimitri Pisarev

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?#

LayerHoldsTypical directiveSet by
Browserhashed assets, fontsmax-age=31536000, immutablefile-name hashing at build
CDN edgepublic pages, cacheable GET endpointss-maxage=120, stale-while-revalidate=600app middleware
Applicationrendered fragments, query resultsLaravel cache or Redis, minutes to hourscode

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.

Gotcha

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.