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\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) 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.
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'sno-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 thanprivate/noStoreis replaced withCache-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. Declareprivate: truefor a page browsers should cache while logged in. - When
If-None-Matchis present, its comparison alone decides, andIf-Modified-Sinceis 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.
Revisions (3)
- v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
- v0.31.0: セッションとの関係、If-None-Match の優先を追記
- Created