Of every performance optimization we have shipped for Laravel clients — OPcache tuning, query fixes, Redis fragment caching, queue offloading — nothing has come close to the impact of full-page HTML edge caching. Each of those techniques shaves a slice off a request that still has to travel to your origin, boot the framework, and render a view. Edge caching removes the request entirely: the visitor gets finished HTML from a Cloudflare data center a few milliseconds away, and your server never hears about it.
And yet almost nobody does it for Laravel. The reason is simple: Laravel touches the session on nearly every web request and answers with a Set-Cookie header, and any well-behaved shared cache refuses to store a response that sets a cookie. So teams cache images and JavaScript, leave every HTML response marked DYNAMIC, and wonder why TTFB from abroad is still 600 ms. This guide covers how we solved it in production on web-pioneer.com — including the mistakes we made on the way.
The architecture: the origin decides, the edge obeys
There is one design decision that everything else hangs on: cacheability logic lives in the Laravel application, not in the Cloudflare dashboard.
Cloudflare's job is reduced to a single dumb instruction: “cache HTML when the origin says it is allowed to.” The origin — a small middleware we call EdgeCache — is the only place that knows whether a given response is safe to share between visitors. It emits Cache-Control: public, s-maxage=600 when it is, and no-cache when it is not.
Two properties make this arrangement robust:
- Default deny. Every response is uncacheable unless it passes an explicit allowlist: anonymous, GET, status 200, HTML. A new route, an error page, an authenticated area — all of them fail safe.
- One source of truth. The rules ship with the codebase, go through code review, and are covered by tests. Nobody has to remember that a dashboard toggle in Cloudflare exists, and staging behaves like production.
Note the header choice: s-maxage applies only to shared caches like Cloudflare, so the edge holds the page for 600 seconds while browsers still revalidate on every visit. That distinction matters — a stale page at the edge can be purged centrally in seconds; a stale page in ten thousand browser caches cannot.
The middleware
Here is the middleware, essentially as it runs in production:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EdgeCache
{
/** Seconds a page may live in Cloudflare's edge cache. */
private const EDGE_TTL = 600;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $this->isCacheable($request, $response)) {
$response->headers->set('Cache-Control', 'no-cache, private');
return $response;
}
// A cookie on a shared-cache response either blocks caching
// or, far worse, hands one visitor's session to everyone.
$response->headers->remove('Set-Cookie');
$response->headers->set(
'Cache-Control',
'public, max-age=0, s-maxage=' . self::EDGE_TTL
);
return $response;
}
private function isCacheable(Request $request, Response $response): bool
{
// Idempotent reads only.
if (! $request->isMethod('GET')) {
return false;
}
// Successful pages only: never cache 404s, redirects or errors.
if ($response->getStatusCode() !== 200) {
return false;
}
// HTML only; APIs and downloads have their own policies.
$contentType = (string) $response->headers->get('Content-Type', '');
if (! str_contains($contentType, 'text/html')) {
return false;
}
// Anonymous traffic only.
if ($request->user() !== null) {
return false;
}
// Flashed session data (form errors, status messages) means
// this particular render belongs to one visitor.
if ($request->hasSession() && $request->session()->get('_flash.new', []) !== []) {
return false;
}
return true;
}
}
Register it at the end of the web middleware group so it sees the fully-built response, sessions included. The interesting line is the Set-Cookie removal. Laravel's session and XSRF cookies are attached to almost every response, and stripping them is what makes anonymous pages cacheable at all. It is safe precisely because of the guards above it: by the time we strip cookies we have already proven the response is an anonymous 200 HTML page with no flashed state, so the cookie being discarded is a throwaway guest session that nothing depends on.
The Cloudflare cache rule — and our 404 war story
On the Cloudflare side you need exactly one Cache Rule: match your hostname, set the response to eligible for cache (Cloudflare does not cache HTML by default), and — this is the part that matters — set Edge TTL to “respect existing headers”, not “override”.
We learned this the honest way. Our first attempt used the override mode with a two-hour TTL, reasoning that a hard TTL at the edge was simpler than trusting origin headers. Within a day we had two production incidents from one toggle. First, Cloudflare cached 404 responses: a bot probed a non-existent URL, the 404 went into the edge cache, and when the real page went live minutes later, visitors in that region kept receiving the cached 404 for hours. Second, we deployed a template fix and watched the old HTML keep serving long after the origin was updated, because override mode ignores whatever Cache-Control the origin sends and pins everything — errors included — for the full TTL.
Respect mode inverts the failure. Since our middleware only emits s-maxage on status-200 HTML, a 404 arrives at Cloudflare carrying no-cache and is never stored. The edge cannot cache anything the application did not explicitly bless. Override mode is fine for a directory of immutable assets; for HTML generated by an application with error states, it is a loaded gun.
The four gotchas that will bite you
1. HEAD requests lie to you. The reflexive way to check headers is curl -I, which sends a HEAD request. Cloudflare cache rules commonly match GET only, so HEAD responses report cf-cache-status: DYNAMIC even when GET traffic is caching perfectly. We spent a genuinely embarrassing amount of time “debugging” a cache that was working fine. Test with a real GET: curl -s -o /dev/null -D - https://example.com/page, run it twice, and look for cf-cache-status: HIT on the second request.
2. Purge on every HTML deploy. A 600-second TTL means a deploy can leave stale pages at the edge for up to ten minutes — fine for a copy tweak, not fine for a bug fix. Make purging part of the pipeline, not a human memory item:
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'
With versioned assets (see below), purging everything is cheap: the next request per page is a MISS that repopulates the cache, and the origin absorbs one render per URL rather than a stampede.
3. A single stray Set-Cookie kills caching — silently. Any package or route that attaches a cookie after your middleware runs flips those pages back to DYNAMIC with no error anywhere. The failure mode is quiet: your hit ratio just sags. Monitor cf-cache-status on your key landing pages (a scheduled synthetic check is enough) so a regression shows up as an alert instead of a slow month.
4. Version-stamp your static assets. Edge caching HTML makes stale assets more dangerous, not less. Browsers hold assets with long max-age locally — we found one returning visitor whose browser had held an old JavaScript bundle for 31 days, long after the HTML referencing it had changed. Purging Cloudflare does nothing for a file that never leaves the visitor's disk. The fix is cache busting in the URL itself — Laravel Mix and Vite do this out of the box, or append ?v=filemtime when you serve files directly. A new URL bypasses every cache between you and the user unconditionally.
Results — and when not to do this
With the middleware and a respect-origin rule in place, anonymous page loads on web-pioneer.com serve straight from Cloudflare's edge with cf-cache-status: HIT and a ~600-second TTL. The wins split in two:
- Latency. Our origin is in Cairo. A visitor in the Gulf or in Europe previously paid the full round trip plus a Laravel boot on every page view; they now get finished HTML from a nearby Cloudflare data center, and TTFB from those regions dropped dramatically — from a perceptible pause to effectively instant.
- Origin load. Every HIT is a request PHP never processes. Traffic spikes on cached pages land on Cloudflare's infrastructure, not on our PHP-FPM pool, which changes both capacity planning and how nervous a link from a high-traffic source makes you.
Just as important is knowing where this technique does not belong. Do not edge-cache shopping carts, checkouts, dashboards, or any page that renders per-user content — a cached “Welcome back, Ahmed” served to the wrong person is a privacy incident, not a performance win. Pages with session-bound CSRF forms need care too: either exclude them or post via JavaScript with a freshly fetched token. The pattern fits content: marketing pages, blogs, listings, documentation — anything where a thousand anonymous visitors should see the same bytes. On most content-heavy sites, that is 90% of traffic, and it is exactly the 90% this architecture takes off your origin.
Web Pioneer builds and operates high-performance Laravel applications, from architecture through production tuning. Explore our services or contact us to talk about making your platform faster.
.jpg)