RRelayer
Home/Operations

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=2592000

Centralize 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).

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=0 on 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_limiter empty. 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: is MISSHIT on re-access, with age:

increasing.

  • cache-control: carries the intended s-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)

Last updated: 2026-05-20
Change history (2)
  • Sidebar reorg: order shift for the new Development category
  • Created