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:
| Parameter | Meaning |
|---|---|
maxAge / sMaxAge | max-age / s-maxage (seconds) |
public / private | Whether the cache may be shared |
noStore / noCache | no-store / no-cache |
mustRevalidate / immutable | Revalidation required / immutable |
vary | Vary header (array) |
etag | Static ETag value |
etagKey | Key for a dynamic ETag (resolved by EtagStore) |
etagWeak | Weak ETag (W/"…") |
lastModified | Last-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) andetagKey(dynamic) are specified, the staticetagtakes 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\RedisEtagStoreConditional GET / 304 Not Modified#
When etag or lastModified is present, safe methods evaluate If-None-Match / If-Modified-Since.
- Emit validation headers (
ETag/Last-Modified/Cache-Control/Vary) - If they match, set
304 Not Modified - 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.
Change history (1)
- Created