Caching means storing the result of expensive work so that you can reuse it. It is the most powerful performance tool, and the one with the most ways to go wrong.
Layers of caching
| Layer | Caches | Where |
|---|---|---|
| OPcache | Compiled PHP code | PHP (shared memory) |
| Data cache | Results of queries, API calls, calculations | Files, APCu, Redis, Memcached |
| Page / fragment cache | Finished HTML | Files, Redis, a reverse proxy |
| HTTP cache | Responses in the browser | Cache-Control, ETag headers |
| CDN | Static files and pages, near the visitor | A CDN provider |
OPcache: the one to switch on
PHP normally compiles each file to bytecode on every request. OPcache keeps the compiled version in memory, so the work happens once. It is bundled with PHP, and it is the biggest single speed-up for most sites. See Production Configuration.
A simple data cache
<?php
final class FileCache
{
public function __construct(private string $folder)
{
if (!is_dir($folder)) {
mkdir($folder, 0755, true);
}
}
public function remember(string $key, int $ttl, callable $compute): mixed
{
$file = $this->folder . "/" . hash("sha256", $key) . ".cache";
if (is_file($file) && filemtime($file) > time() - $ttl) {
return unserialize(file_get_contents($file), ["allowed_classes" => false]);
}
$value = $compute();
file_put_contents($file, serialize($value), LOCK_EX);
return $value;
}
public function forget(string $key): void
{
@unlink($this->folder . "/" . hash("sha256", $key) . ".cache");
}
}
$cache = new FileCache(__DIR__ . "/cache");
$computations = 0;
$compute = function () use (&$computations): array {
$computations++;
return ["top" => ["Pen", "Book"]]; // pretend this is a heavy query
};
$cache->remember("top-products", 300, $compute);
$cache->remember("top-products", 300, $compute);
$cache->forget("top-products");
$cache->remember("top-products", 300, $compute);
echo "Computed $computations times for 3 requests";
Computed 2 times for 3 requests
Three requests, two computations: the second one hit the cache, and after forget() the third had to compute again.
Safety notes: the cache key is hashed (so keys cannot become file paths), and unserialize() is told not to create objects (allowed_classes => false), because unserializing untrusted data can run code.
Faster stores
| Store | Notes |
|---|---|
| APCu | In-memory cache inside PHP. Very fast, per server |
| Redis / Memcached | Shared cache servers. Work across several web servers |
| Files | Simple, no extra software, slower |
| Database | Possible, but you are using the slow thing to cache the slow thing |
A common library: symfony/cache (supports all of them, and implements PSR-6 and PSR-16).
The hard part: invalidation
“There are only two hard things in computer science: cache invalidation and naming things.”
When the data changes, the cached copy is stale. You have three options:
- Time to live (TTL): accept that data may be up to N seconds old. Simple and often good enough.
- Explicit invalidation: delete the cache when the data changes (after a product update,
forget("top-products")). - Versioned keys: include a version in the key (
products-v12). Increasing the version makes old entries unreachable.
Choose by how stale the data may be. A weather box can be five minutes old. A stock level cannot.
What to cache
Good candidates: data that is expensive to produce and requested often and changes rarely.
Do not cache:
- Per-user private data in a shared cache without a user-specific key (users could see each other’s data).
- Anything that must be exactly current (balances, stock during checkout).
- Things that are already fast.
HTTP caching
Tell browsers and proxies how to cache a response.
<?php
header("Cache-Control: public, max-age=3600"); // cache for an hour
$etag = '"' . md5($content) . '"';
header("ETag: " . $etag);
if (($_SERVER["HTTP_IF_NONE_MATCH"] ?? "") === $etag) {
http_response_code(304); // Not Modified: no body needed
exit;
}
For private pages, use Cache-Control: private, no-store.
WordPress
WordPress has an object cache (wp_cache_get, wp_cache_set, backed by Redis or Memcached with a plugin) and transients (see WordPress Functions). Page caching plugins save finished HTML. The same ideas apply.
Cache stampede
When a popular cache entry expires, hundreds of requests may all recompute it at the same moment. Use a short lock (only one request recomputes), or refresh the entry a little before it expires.