Serve 304s from PHP: Conditional GETs for Dynamic Content
Your web server already does proper HTTP caching for static files - ETags, 304s, the works. Then a request hits a .php file and all of that stops: full regeneration, full retransfer, every visit, even when nothing changed. The browser literally sends headers asking "has this changed since last time?" - most PHP just never reads them.
The mechanism in one exchange
First visit: you send content plus a fingerprint. Repeat visit: the browser returns the fingerprint in If-None-Match; if it still matches, you answer 304 Not Modified with an empty body and the browser uses its copy. Bandwidth saved, and users perceive instant loads.
function conditionalGet(string $etag, ?int $lastModified = null): void
{
header('ETag: "' . $etag . '"');
if ($lastModified) {
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
}
$ifNoneMatch = trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? '', 'W/"');
$ifModified = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? null;
$matched = ($ifNoneMatch && hash_equals($etag, $ifNoneMatch))
|| (!$ifNoneMatch && $ifModified && $lastModified
&& strtotime($ifModified) >= $lastModified);
if ($matched) {
http_response_code(304);
exit; // empty body - that's the whole point
}
}
The art is picking the fingerprint
The ETag must change when the response would - and computing it must be much cheaper than rendering:
// A blog post: updated_at is the truth
conditionalGet(md5($post->id . '|' . $post->updated_at), strtotime($post->updated_at));
// A listing: latest change + row count catches edits AND deletions
$row = $pdo->query('SELECT MAX(updated_at) m, COUNT(*) c FROM products')->fetch();
conditionalGet(md5($row['m'] . '|' . $row['c']));
// An API response you already rendered: hash the payload (still saves transfer, not CPU)
conditionalGet(md5($json));
One cheap indexed query deciding whether to skip template rendering and a 40KB transfer is an excellent trade. Note the version-everything trick: fold the user's role or a site-wide "content version" (my CMS bumps one on every admin save) into the hash, and invalidation stays honest.
The fine print that separates working from broken
- Send
Cache-Control: private, no-cachealongside (for per-user pages). Counterintuitively, no-cache doesn't mean "don't store" - it means "revalidate before using", which is exactly the 304 dance. Without it, heuristic caching may show stale pages with no request at all. - Vary responses (logged-in vs guest) must vary the ETag - hence folding identity into the hash. Never serve one ETag for two different renderings.
- That W/" trim handles weak validators and quotes from proxies; hash_equals is habit-forming for any token comparison (here's why).
- Sessions: remember session_write_close() before long comparisons if you've loaded one - and note PHP's session module may inject its own no-store headers you'll want to override with session_cache_limiter('').
Fifteen lines, and your dynamic pages join the caching conversation the rest of the web has been having since 1997.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.