databases storage and caching

HTTP Cache Headers

HTTP cache headers tell browsers, proxies, CDNs, and other clients whether a response can be reused. Good headers make applications faster and cheaper to run. Bad headers can leak private data or make users see stale content.

The main skill is choosing the right caching rule for the response. A public product image, a logged-in account page, and an API response with user-specific data should not get the same headers.

The Important Headers

Cache-Control is the main header. It describes who may cache the response and for how long.

Common directives include:

  • public, meaning shared caches such as CDNs may store it;
  • private, meaning only the user's browser should store it;
  • no-store, meaning do not store it at all;
  • max-age=300, meaning fresh for 300 seconds;
  • s-maxage=300, meaning shared caches may keep it for 300 seconds;
  • must-revalidate, meaning the cache must check freshness after expiry.
PHP example
<?php

declare(strict_types=1);

function cacheControlForPage(string $pageType): string
{
    return match ($pageType) {
        'public_product' => 'public, max-age=300, s-maxage=600',
        'account_dashboard' => 'private, no-cache',
        'payment_receipt' => 'no-store',
        default => 'no-cache',
    };
}

echo cacheControlForPage('public_product') . PHP_EOL;
echo cacheControlForPage('payment_receipt') . PHP_EOL;

// Prints:
// public, max-age=300, s-maxage=600
// no-store

no-cache does not mean "do not store". It means a cache may store the response, but must revalidate before using it again. Use no-store when a response must not be stored.

Public, Private, And No-Store

Public responses can be reused by shared caches. Product listing pages, public blog posts, public documentation, and hashed static assets are common examples.

Private responses are specific to one user. A browser may cache them, but shared caches should not. Account pages, user settings, and authenticated dashboards are private.

No-store responses should not be written to browser cache or shared cache. Use this for sensitive data such as payment pages, medical information, password reset pages, and highly confidential documents.

PHP example
<?php

declare(strict_types=1);

function responseCachePolicy(bool $isAuthenticated, bool $containsSensitiveData): string
{
    if ($containsSensitiveData) {
        return 'no-store';
    }

    if ($isAuthenticated) {
        return 'private, no-cache';
    }

    return 'public, max-age=300';
}

echo responseCachePolicy(true, false) . PHP_EOL;
echo responseCachePolicy(true, true) . PHP_EOL;

// Prints:
// private, no-cache
// no-store

The safe default for unknown dynamic pages is usually conservative. Cache public content deliberately, not accidentally.

ETags

An ETag is a validator. It identifies a particular version of a response. If the browser already has that version, it can send If-None-Match on the next request. The server can reply 304 Not Modified instead of sending the full response body again.

PHP example
<?php

declare(strict_types=1);

function productEtag(int $productId, string $updatedAt): string
{
    return '"' . sha1('product:' . $productId . ':' . $updatedAt) . '"';
}

echo productEtag(123, '2026-05-20T10:30:00Z') . PHP_EOL;

// Prints:
// "bcfaef467893ee1cd9f9ecc8c26b5c4bf6f6004b"

ETags should change when the response content changes. They should not include secrets or raw personal data.

Returning 304 Not Modified

A 304 Not Modified response means the cached copy is still valid. It should not include the full body.

PHP example
<?php

declare(strict_types=1);

function conditionalGetDecision(?string $ifNoneMatch, string $currentEtag): array
{
    if ($ifNoneMatch === $currentEtag) {
        return [
            'status' => 304,
            'send_body' => false,
        ];
    }

    return [
        'status' => 200,
        'send_body' => true,
    ];
}

print_r(conditionalGetDecision('"abc"', '"abc"'));

// Prints:
// [status] => 304
// [send_body] =>

Frameworks often handle this through response helpers. The underlying behaviour is still worth knowing because it explains what the browser and CDN are doing.

Last-Modified

Last-Modified is another validator. Instead of comparing an ETag, the client sends If-Modified-Since, and the server decides whether the content has changed since that time.

PHP example
<?php

declare(strict_types=1);

function httpDate(int $timestamp): string
{
    return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
}

echo httpDate(strtotime('2026-05-20 10:30:00 UTC')) . PHP_EOL;

// Prints:
// Wed, 20 May 2026 10:30:00 GMT

Last-Modified is easy to understand and works well when a record has a reliable updated_at value. ETags are more flexible when the response depends on several values.

Static Assets

Static assets with fingerprinted filenames can be cached for a long time. If the filename changes when the content changes, the old URL can safely stay cached.

PHP example
<?php

declare(strict_types=1);

function staticAssetCacheControl(bool $filenameIsFingerprinted): string
{
    if ($filenameIsFingerprinted) {
        return 'public, max-age=31536000, immutable';
    }

    return 'public, max-age=300';
}

echo staticAssetCacheControl(true) . PHP_EOL;

// Prints:
// public, max-age=31536000, immutable

This is why build tools often produce filenames like app.8f3a91c.css. The content hash makes long cache lifetimes safe.

APIs And User Data

API responses need the same care as HTML. A public catalogue API may be cacheable. A user profile API should normally be private or no-store. A response that depends on the Authorization header must not be cached as if it were public.

PHP example
<?php

declare(strict_types=1);

function apiCacheHeaders(bool $usesAuthorizationHeader, bool $isPublicData): array
{
    if ($usesAuthorizationHeader || !$isPublicData) {
        return [
            'Cache-Control' => 'private, no-store',
            'Vary' => 'Authorization',
        ];
    }

    return [
        'Cache-Control' => 'public, max-age=120',
    ];
}

print_r(apiCacheHeaders(true, false));

// Prints:
// [Cache-Control] => private, no-store
// [Vary] => Authorization

Vary tells caches which request headers affect the response. It is especially important when responses differ by Accept-Encoding, Authorization, language, or content type negotiation.

PHP Headers

In plain PHP, headers must be sent before the response body starts. In frameworks, response objects usually manage this for you.

PHP example
<?php

declare(strict_types=1);

function productResponseHeaders(int $productId, string $updatedAt): array
{
    return [
        'Cache-Control' => 'public, max-age=300, s-maxage=600',
        'ETag' => productEtag($productId, $updatedAt),
        'Last-Modified' => httpDate(strtotime($updatedAt)),
    ];
}

print_r(productResponseHeaders(123, '2026-05-20 10:30:00 UTC'));

// Prints:
// [Cache-Control] => public, max-age=300, s-maxage=600
// [ETag] => "17aaedc67cc3d7e239a7404b99035c115aa99190"

This example returns a header array so it can run anywhere. A real controller would attach these values to the HTTP response.

What To Check

Before moving on, make sure you can:

  • explain what Cache-Control does;
  • choose between public, private, no-cache, and no-store;
  • use max-age for browser freshness and s-maxage for shared caches;
  • explain how ETags and Last-Modified support 304 Not Modified;
  • avoid public caching for authenticated or sensitive responses;
  • use long-lived caching only for fingerprinted static assets;
  • remember that headers must match the data sensitivity of the response.

Practice

Practice: Choose HTTP Cache Headers

Build a small PHP helper that chooses HTTP cache headers for common response types.

Requirements

  • Return public cache headers for public product pages.
  • Return private or no-store headers for authenticated and sensitive pages.
  • Generate an ETag from a record ID and updated timestamp.
  • Return a 304 decision when If-None-Match matches the current ETag.
  • Include Vary: Authorization for responses that depend on an auth header.
  • Show one public product example and one sensitive account example.

Return arrays rather than calling header() directly so the example can be run from the command line.

Show solution

This solution models the HTTP decisions a controller would apply before returning a response.

PHP example
<?php

declare(strict_types=1);

function etagForRecord(string $type, int $id, string $updatedAt): string
{
    return '"' . sha1($type . ':' . $id . ':' . $updatedAt) . '"';
}

function cacheHeadersForResponse(
    string $responseType,
    int $recordId,
    string $updatedAt,
    bool $usesAuthorizationHeader
): array {
    if ($responseType === 'sensitive_account') {
        return [
            'status' => 200,
            'headers' => [
                'Cache-Control' => 'no-store',
                'Vary' => $usesAuthorizationHeader ? 'Authorization' : null,
            ],
            'send_body' => true,
        ];
    }

    $headers = [
        'Cache-Control' => 'public, max-age=300, s-maxage=600',
        'ETag' => etagForRecord($responseType, $recordId, $updatedAt),
    ];

    if ($usesAuthorizationHeader) {
        $headers['Vary'] = 'Authorization';
    }

    return [
        'status' => 200,
        'headers' => $headers,
        'send_body' => true,
    ];
}

function applyConditionalGet(array $response, ?string $ifNoneMatch): array
{
    $etag = $response['headers']['ETag'] ?? null;

    if (is_string($etag) && $ifNoneMatch === $etag) {
        $response['status'] = 304;
        $response['send_body'] = false;
    }

    return $response;
}

$product = cacheHeadersForResponse('public_product', 123, '2026-05-20T10:30:00Z', false);
$product = applyConditionalGet($product, $product['headers']['ETag']);

$account = cacheHeadersForResponse('sensitive_account', 42, '2026-05-20T10:30:00Z', true);

echo $product['status'] . PHP_EOL;
echo $product['send_body'] ? 'body' : 'no body';
echo PHP_EOL;
echo $account['headers']['Cache-Control'] . PHP_EOL;
echo $account['headers']['Vary'] . PHP_EOL;

// Prints:
// 304
// no body
// no-store
// Authorization

The important distinction is safety. Public content can be cached aggressively when the freshness rule is clear. Sensitive account content should not be stored, even if caching it would be faster.