Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Serve 304s from PHP: Conditional GETs for Dynamic Content

C
Carlo Fontanos
· 3 min read

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-cache alongside (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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading