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
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
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
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
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
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
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
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
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-Controldoes; - choose between
public,private,no-cache, andno-store; - use
max-agefor browser freshness ands-maxagefor shared caches; - explain how ETags and
Last-Modifiedsupport304 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
publiccache headers for public product pages. - Return
privateorno-storeheaders for authenticated and sensitive pages. - Generate an ETag from a record ID and updated timestamp.
- Return a
304decision whenIf-None-Matchmatches the current ETag. - Include
Vary: Authorizationfor 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
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.