Relayer
DocsFeatureshttp-cache

HTTP Caching & ETag#

You can declare an HTTP cache policy per page. The framework emits Cache-Control / Vary / ETag / Last-Modified and returns 304 Not Modified for conditional requests on safe methods (GET/HEAD).

Class style (#[Cache] attribute)#

use Polidog\Relayer\Http\Cache;

#[Cache(maxAge: 3600, public: true, etag: 'home-v1')]
final class HomePage extends PageComponent { /* ... */ }

Main parameters you can specify:

ParameterMeaning
maxAge / sMaxAgemax-age / s-maxage (seconds)
public / privateWhether the cache may be shared
noStore / noCacheno-store / no-cache
mustRevalidate / immutableRevalidation required / immutable
varyVary header (array)
etagStatic ETag value
etagKeyKey for a dynamic ETag (resolved by EtagStore)
etagWeakWeak ETag (W/"…")
lastModifiedLast-Modified (a string strtotime() can parse)

The functional style uses $ctx->cache() instead of the attribute:

return function (PageContext $ctx): Closure {
    $ctx->cache(new Cache(maxAge: 60, public: true, etagKey: 'feed'));

    return function () use ($ctx): \Polidog\Rosanna\Runtime\Element {
        // Heavy work goes here. Not executed on a 304.
    };
};

The factory runs on every request (lightweight); the render closure it returns runs only when there is no 304. So a 304 short-circuit saves the entire heavy body of the render.

Static ETag, and dynamic ETag via EtagStore#

Static ETag (etag)#

#[Cache(maxAge: 3600, public: true, etag: 'home-v1')]
final class HomePage extends PageComponent { /* ... */ }

etag: 'home-v1' suits content that only changes on deploy.

Dynamic ETag (etagKey + EtagStore)#

Data-driven pages declare etagKey:, and EtagStore resolves the current value at request time. If the client's If-None-Match matches, a 304 is returned without running the page or any repository code (without touching the DB).

#[Cache(maxAge: 60, public: true, etagKey: 'user-list')]
final class UsersPage extends PageComponent { /* ... */ }

If both etag (static) and etagKey (dynamic) are specified, the static etag takes precedence.

Default backend: FileEtagStore#

FileEtagStore is registered by default and stores values per key under $projectRoot/var/cache/etags/ (one file per sha1(key), with atomic write-then-rename). No configuration required.

The code that updates data updates the ETag value whenever it changes:

use Polidog\Relayer\Http\EtagStore;

final class UserRepository
{
    public function __construct(private readonly EtagStore $etags) {}

    public function save(User $user): void
    {
        // ... persistence
        $this->etags->set('user-list', \sha1((string) \microtime(true)));
    }
}

EtagStore methods: get(string $key): ?string / set(string $key, string $etag): void / forget(string $key): void.

Custom backend (Redis example)#

use Polidog\Relayer\Http\EtagStore;

final class RedisEtagStore implements EtagStore
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $prefix = 'etag:',
    ) {}

    public function get(string $key): ?string
    {
        $v = $this->redis->get($this->prefix . $key);
        return \is_string($v) && '' !== $v ? $v : null;
    }

    public function set(string $key, string $etag): void
    {
        $this->redis->set($this->prefix . $key, $etag);
    }

    public function forget(string $key): void
    {
        $this->redis->del($this->prefix . $key);
    }
}

Swap it in via config/services.yaml:

services:
  Polidog\Relayer\Http\EtagStore:
    alias: App\Infrastructure\RedisEtagStore

Conditional GET / 304 Not Modified#

When etag or lastModified is present, safe methods evaluate If-None-Match / If-Modified-Since.

  1. Emit validation headers (ETag / Last-Modified / Cache-Control / Vary)
  2. If they match, set 304 Not Modified
  3. End the request before the body is rendered

ETag comparison follows the weak comparison of RFC 7232 §2.3.2: W/"v1" and "v1" match, and * matches any tag.

A request with a session cookie or an Authorization header never gets a 304; it is always rendered. The same goes for a request that started a session by the time the policy is evaluated (for example when middleware.php logs the visitor in from a remember-me cookie), even if it was closed with session_write_close() before $next(). Validators do not say whose request it is, so this keeps a browser that viewed a page anonymously from reusing the anonymous copy after logging in.

A response to a request with an Authorization header is sent with Cache-Control: private, no-store for any policy other than private / noStore, on class and function pages alike. This includes a header a middleware added with $request->withHeader() and the REDIRECT_HTTP_AUTHORIZATION that Apache/CGI passes (which Request reads as authorization when the real header is missing). A session cookie alone does not trigger this.

Complete example#

#[Cache(
    maxAge: 3600,
    public: true,
    vary: ['Accept-Language'],
    etag: 'home-v1',
    etagWeak: true,
    lastModified: '2025-01-15 10:00:00 UTC',
)]
final class HomePage extends PageComponent { /* ... */ }

Sessions#

  • A declared cache policy is not overwritten by session_start(). A page with no policy that uses the session keeps PHP's no-store.
  • A response that used the session is never left storable by shared caches. If the request opened a session anywhere (a new one or one resumed from the cookie, even if it was closed with session_write_close() before the page), any policy other than private / noStore is replaced with Cache-Control: private, no-store. A #[Cache(public: true)] page that reads $auth->user() is uncached for a logged-in visitor and follows its policy for an anonymous one. Declare private: true for a page browsers should cache while logged in.
  • When If-None-Match is present, its comparison alone decides, and If-Modified-Since is ignored.

How this site uses it#

This site caches read paths (home / articles / search / /api/search) with a time-based two-knob scheme. The shared policy App\PageCache::timed() emits Cache-Control: public, max-age=300, s-maxage=2592000 (browser 5 minutes / CDN 30 days) plus a static etag of the body hash (etagKey/EtagStore is not used — the ETag value itself is the body digest).

While a cache is hit, the origin (PHP / remote Turso) is never touched, and only revalidation after the TTL expires short-circuits cheaply to a 304 via the content-addressed ETag. So edits via bin/docs are not reflected immediately: browsers reflect them after at most max-age, and the CDN reflects them on natural expiry or a purge. (The old setup preferred immediate reflection with no-cache + body-hash ETag, but it caused a remote Turso round trip on every request, so it was changed to time-based.) For the key points under a CDN (the Cache Rule that makes s-maxage effective, the trap of a session cookie causing a full BYPASS, etc.) see CDN Caching; for deployment in general see Deployment.

Last revised — 2026-11-04
Revisions (3)
  • v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
  • v0.31.0: セッションとの関係、If-None-Match の優先を追記
  • Created