Skip to content

Cache-Control headers + server-side HTTP cache for anonymous read-only responses #638

@jbourdin

Description

@jbourdin

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:

  1. 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/*)
  2. 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)
  3. Sets Vary: Accept-Language, Cookie so the CDN can cache per locale and bypass cache for authenticated users (whose request carries a session cookie).
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions