CDN Caching#
The Cache-Control you declare in HTTP Caching & ETag also works directly on a CDN's (Cloudflare etc.) edge cache. For a read-heavy site, you can hit on the edge and reduce reaching the origin (PHP / remote DB) to nearly zero. However, putting HTML on the CDN requires CDN-side configuration, and there is a Relayer-specific trap where a session cookie causes a full BYPASS, so this focuses on those two points.
Separating browser and CDN (max-age vs s-maxage)#
Cache's maxAge is the browser's freshness, sMaxAge is the shared (CDN) cache's freshness. A browser cache cannot be purged (once served, a fix never arrives), whereas a CDN can be purged, so setting them asymmetrically is the standard practice.
// short for the browser, long for the CDN; edits reflect immediately via a CDN purge
new Cache(public: true, maxAge: 300, sMaxAge: 2592000, etag: $etag);
// → Cache-Control: public, max-age=300, s-maxage=2592000Centralize the shared policy in one place (e.g. App\PageCache::timed()) and keep the TTL as the only knob there to make operations easy.
Making Cloudflare cache HTML#
Cloudflare by default caches only static assets (extension-based) and treats HTML / JSON as "dynamic," passing them through. Adding s-maxage alone is not enough. Create a Cache Rule for the target paths (the equivalent of the old Page Rule's "Cache Everything" = Eligible for cache) and set the Edge TTL to respect origin (Use origin cache-control). Now Cloudflare adopts s-maxage and caches at the edge for the specified number of seconds.
- Target every read path (e.g.
/,/docs/*,/search,/api/*).
If the rule's path scope covers only part of them, the pages outside it stay MISS forever and are never cached.
- If you operate edits as CDN purges, purge the zone after each deploy
(e.g. have CI hit Cloudflare API's purge_everything).
The biggest trap: session cookie causes a full BYPASS#
Cloudflare unconditionally bypasses caching for any response that includes a Set-Cookie (cf-cache-status: BYPASS). Here is the Relayer-specific pitfall.
Relayer renders each page as a use-php component, and AppRouter creates a page route's ComponentState with the default StorageType::Session. Even a static page that uses no useState gets this Session storage. However, SessionStorage is lazy, and session_start() runs only when the state is first accessed. A page that does not read or write state never starts a session and emits no Set-Cookie: PHPSESSID, so it is edge-cacheable. Only pages that hold state (useState / auth / CSRF) emit Set-Cookie, and those are BYPASSed as intended.
Points:
- **Do not block this by setting
session.use_cookies=0on the app
side.** The CDN may go through, but it silently breaks pages that later use auth / CSRF / useState. The correct fix is "leave it to the framework."
- For shared components that hold no state, set them to
fc(..., StorageType::Memory) so they are not dragged along by the default Session, as a safety net (Memory does not touch the session; Snapshot fails in production when USEPHP_SNAPSHOT_SECRET is unset, so Memory is the safe choice for stateless use).
- Separately, keep
session.cache_limiterempty. When a session
starts, PHP injects Cache-Control: no-store, no-cache, must-revalidate, overriding the per-page cache policy.
// public/index.php, before Relayer::boot()
\ini_set('session.cache_limiter', '');Verifying it works#
Measure the response headers for real.
curl -sI https://example.com/docs/some-page | \
grep -iE 'cache-control|cf-cache-status|age|set-cookie'- There is no
set-cookie:(if present, it is the cause of BYPASS). cf-cache-status:isMISS→HITon re-access, withage:
increasing.
cache-control:carries the intendeds-maxage.
DYNAMIC means the Cache Rule is not set (HTML not targeted), BYPASS means cache excluded due to Set-Cookie etc. Right after purging the zone the edge is unpopulated, so MISS continues for a while — wait a few tens of seconds and check again.
Reflecting edits and purging#
Within max-age / s-maxage the origin is not queried, so even after you update content the CDN stays stale for at most s-maxage seconds. If you have CI that purges the zone on deploy, deploy reflection is immediate, but in operations that update only content without a deploy (editing the external DB directly, etc.) that update does not appear on the CDN until you purge. If you use a long s-maxage, always build a CDN purge into the content-update path too.
Related: HTTP Caching & ETag · Deployment (Dockerfile)
Change history (2)
- Sidebar reorg: order shift for the new Development category
- Created