RRelayer
Home/Features

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\UsePhp\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.

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 { /* ... */ }

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 updated: 2026-05-19
Change history (1)
  • Created