Problem
After #634 / v1.12.25, anonymous read-only requests no longer allocate a session row and no longer carry Set-Cookie: PHPSESSID=… on the response. That unblocked CDN edge caching at the Bunny.net layer (see sibling repo expandedDecksInfra, commit 297c019 for the bot-block rule). But the origin still emits no Cache-Control header on those responses, so:
- Bunny treats them as uncacheable by default (origin policy wins)
- Each anonymous browser hits the origin every time
- No conditional-GET handshake (
ETag / Last-Modified / If-None-Match / If-Modified-Since)
- No Symfony reverse-proxy cache to short-circuit the request before it reaches the controller
This issue is the natural follow-up that turns the work in #634 into actual cache hits.
Proposed approach
Track 1 — Emit Cache-Control on anonymous responses
A response listener (priority just before Symfony\Component\HttpKernel\EventListener\ResponseListener) that:
- Bails immediately when any of these are true (these responses must never be cached):
- User is authenticated (security token !=
null)
- A session cookie is present on the request
- The response has a
Set-Cookie of any session/auth flavor
- Status code is not 200/203/300/301/302/404/410
- Route is in a deny-list (
/login, /register, /admin/*, /api/* with mutation methods, /messenger/webhook/*, /health/*)
- Adds
Cache-Control: public, s-maxage=<route-ttl>, max-age=<route-ttl/5> when none of the above bail conditions hit. Default TTLs per route family (configurable):
- Catalog listings (
app_archetype_list, app_deck_list, app_event_list, CMS index): 300 s (5 min)
- Detail pages (
app_archetype_show, app_deck_show, app_event_show, app_page_show): 300 s
- Homepage (
app_home): 120 s
- Sitemap, RSS, ICS feeds: 3600 s (1 h)
- Sets
Vary: Accept-Language, Cookie so the CDN can cache per locale and bypass cache for authenticated users (whose request carries a session cookie).
- Emits
Last-Modified sourced from per-route metadata where it's cheap to compute (e.g. archetype catalog → max(Archetype.lastPublishedAt); CMS page → Page.lastPublishedAt). Symfony's ResponseListener will then generate the matching ETag via framework.http_cache.private_headers if enabled, OR we set it directly from a content hash.
A #[Cache(public: true, smaxage: 300)] attribute on individual controller actions is the configurable equivalent of this listener — Symfony reads it natively. The choice is between the centralized listener (one place to evolve the policy, fewer per-action declarations) and the per-action attributes (more discoverable, opt-in). Recommended: centralize via the listener for the bail conditions + apply per-route TTL via a small allow-list mapping; let new public routes opt in explicitly.
Track 2 — Symfony HTTP reverse-proxy cache
Enable framework.http_cache: true in prod so Symfony's built-in reverse proxy short-circuits cacheable requests before they reach the controller. This is the same engine the CDN uses, but co-located with the app — useful when:
- A request bypasses the CDN (direct origin call, health-check probe, debugging)
- Single-app-pod scenarios where the CDN's regional cache is far from the user
The Symfony reverse proxy reads the same Cache-Control: public, s-maxage=… headers Track 1 emits, so the two tracks reuse the same metadata.
Track 3 — Conditional GET on cacheable detail pages
For pages with a well-defined lastModified (CMS page, archetype show, deck show, event show), set the Last-Modified header. Symfony's Response::isNotModified($request) then auto-converts a matching If-Modified-Since into a 304 with no body — saving bandwidth even when the CDN does fetch from origin.
Acceptance criteria
- A 100-request anonymous loop against
/en/archetypes returns identical ETag / Last-Modified and shows 95 %+ hits in the Bunny dashboard after a warm-up.
- The same loop with
If-Modified-Since set to the previous response's Last-Modified returns 304 from the origin (verifying the conditional-GET path).
- Logging in via the normal flow returns a response with no
Cache-Control: public and with Vary: Cookie — the CDN must not serve a stale anonymous version to the logged-in user.
- Authenticated traffic does not get the
public header anywhere.
- Sitemap / RSS / ICS feeds carry the higher TTL.
- Documentation note in
docs/installation.md (or a new docs/technicalities/http_cache.md) covers the bail conditions, the per-route TTL map, and how to opt new routes in or out.
Files likely touched
src/EventListener/HttpCacheResponseListener.php (new) — the centralized listener
config/packages/framework.yaml — enable http_cache in prod
config/services.yaml or config/packages/cache_control.yaml — per-route TTL map
- Specific controllers (
ArchetypeCatalogController, ArchetypeDetailController, DeckController, EventController, PageController, Sitemap*Controller, RSS / ICS controllers) — emit Last-Modified where the source-of-truth timestamp is cheap to compute
docs/technicalities/http_cache.md (new) — operational note
Dependencies / context
Problem
After #634 / v1.12.25, anonymous read-only requests no longer allocate a session row and no longer carry
Set-Cookie: PHPSESSID=…on the response. That unblocked CDN edge caching at the Bunny.net layer (see sibling repoexpandedDecksInfra, commit297c019for the bot-block rule). But the origin still emits noCache-Controlheader on those responses, so:ETag/Last-Modified/If-None-Match/If-Modified-Since)This issue is the natural follow-up that turns the work in #634 into actual cache hits.
Proposed approach
Track 1 — Emit
Cache-Controlon anonymous responsesA response listener (priority just before
Symfony\Component\HttpKernel\EventListener\ResponseListener) that:null)Set-Cookieof any session/auth flavor/login,/register,/admin/*,/api/*with mutation methods,/messenger/webhook/*,/health/*)Cache-Control: public, s-maxage=<route-ttl>, max-age=<route-ttl/5>when none of the above bail conditions hit. Default TTLs per route family (configurable):app_archetype_list,app_deck_list,app_event_list, CMS index): 300 s (5 min)app_archetype_show,app_deck_show,app_event_show,app_page_show): 300 sapp_home): 120 sVary: Accept-Language, Cookieso the CDN can cache per locale and bypass cache for authenticated users (whose request carries a session cookie).Last-Modifiedsourced from per-route metadata where it's cheap to compute (e.g. archetype catalog →max(Archetype.lastPublishedAt); CMS page →Page.lastPublishedAt). Symfony'sResponseListenerwill then generate the matchingETagviaframework.http_cache.private_headersif enabled, OR we set it directly from a content hash.A
#[Cache(public: true, smaxage: 300)]attribute on individual controller actions is the configurable equivalent of this listener — Symfony reads it natively. The choice is between the centralized listener (one place to evolve the policy, fewer per-action declarations) and the per-action attributes (more discoverable, opt-in). Recommended: centralize via the listener for the bail conditions + apply per-route TTL via a small allow-list mapping; let new public routes opt in explicitly.Track 2 — Symfony HTTP reverse-proxy cache
Enable
framework.http_cache: trueinprodso Symfony's built-in reverse proxy short-circuits cacheable requests before they reach the controller. This is the same engine the CDN uses, but co-located with the app — useful when:The Symfony reverse proxy reads the same
Cache-Control: public, s-maxage=…headers Track 1 emits, so the two tracks reuse the same metadata.Track 3 — Conditional GET on cacheable detail pages
For pages with a well-defined
lastModified(CMS page, archetype show, deck show, event show), set theLast-Modifiedheader. Symfony'sResponse::isNotModified($request)then auto-converts a matchingIf-Modified-Sinceinto a 304 with no body — saving bandwidth even when the CDN does fetch from origin.Acceptance criteria
/en/archetypesreturns identicalETag/Last-Modifiedand shows 95 %+ hits in the Bunny dashboard after a warm-up.If-Modified-Sinceset to the previous response'sLast-Modifiedreturns 304 from the origin (verifying the conditional-GET path).Cache-Control: publicand withVary: Cookie— the CDN must not serve a stale anonymous version to the logged-in user.publicheader anywhere.docs/installation.md(or a newdocs/technicalities/http_cache.md) covers the bail conditions, the per-route TTL map, and how to opt new routes in or out.Files likely touched
src/EventListener/HttpCacheResponseListener.php(new) — the centralized listenerconfig/packages/framework.yaml— enablehttp_cachein prodconfig/services.yamlorconfig/packages/cache_control.yaml— per-route TTL mapArchetypeCatalogController,ArchetypeDetailController,DeckController,EventController,PageController,Sitemap*Controller, RSS / ICS controllers) — emitLast-Modifiedwhere the source-of-truth timestamp is cheap to computedocs/technicalities/http_cache.md(new) — operational noteDependencies / context
Set-Cookie: PHPSESSID=…which forces the CDN and any reverse proxy to bypass cache. Already done.expandedDecksInfra, commit297c019) blocks the worst crawlers at the CDN before they hit origin. This issue makes the remaining legit anonymous traffic actually benefit from caching.