Caching And Performance
Every application eventually hits a performance wall. Database queries slow down as tables grow, API calls add latency, and complex calculations repeat themselves unnecessarily. Caching is the tool that breaks through that wall — storing the result of expensive work so it can be reused instantly.
PHP applications cache at multiple levels: in-memory arrays within a single request, files on disk between requests, shared stores like Redis or Memcached across servers. This lesson builds each layer from scratch so you understand exactly what a caching library does under the hood.
What Is a Cache?
A cache is a key-value store that trades memory or disk space for speed. You store the result of an operation under a key, then check that key before performing the operation again. If the key exists and has not expired, return the stored value. If it does not, compute the result, store it, and return it.
This pattern is called cache-aside (or lazy loading) and it is the most common strategy in PHP applications:
<?php
class SimpleCache {
private array $store = [];
public function get(string $key): mixed {
return $this->store[$key] ?? null;
}
public function set(string $key, mixed $value): void {
$this->store[$key] = $value;
}
public function has(string $key): bool {
return array_key_exists($key, $this->store);
}
public function forget(string $key): void {
unset($this->store[$key]);
}
}
$cache = new SimpleCache();
// Simulate an expensive database call
function fetchUserFromDb(int $id): array {
echo " [DB] Querying user {$id}...\n";
return ['id' => $id, 'name' => 'Alice', 'email' => 'alice@example.com'];
}
function getUser(int $id, SimpleCache $cache): array {
$key = "user:{$id}";
if ($cache->has($key)) {
echo " [CACHE] Hit for user {$id}\n";
return $cache->get($key);
}
$user = fetchUserFromDb($id);
$cache->set($key, $user);
return $user;
}
echo "First call:\n";
$user = getUser(1, $cache);
echo " Got: {$user['name']}\n\n";
echo "Second call (cached):\n";
$user = getUser(1, $cache);
echo " Got: {$user['name']}\n";
The second call never touches the database. That is the entire value proposition of caching.
TTL: Time-To-Live Expiration
An in-memory array cache lives only for the duration of a single PHP request. Between requests, you need persistence — and with persistence comes the problem of stale data. A product price cached yesterday may be wrong today.
The solution is a TTL (time-to-live): an expiry timestamp stored alongside each value. When you retrieve a cached entry, you first check whether it has expired:
<?php
class TtlCache {
private array $store = [];
public function set(string $key, mixed $value, int $ttlSeconds = 60): void {
$this->store[$key] = [
'value' => $value,
'expires' => time() + $ttlSeconds,
];
}
public function get(string $key): mixed {
if (!isset($this->store[$key])) {
return null;
}
$entry = $this->store[$key];
if (time() > $entry['expires']) {
unset($this->store[$key]);
echo " [CACHE] Expired: {$key}\n";
return null;
}
return $entry['value'];
}
public function has(string $key): bool {
return $this->get($key) !== null;
}
}
$cache = new TtlCache();
// Cache a product price for 5 seconds
$cache->set('price:42', 29.99, ttlSeconds: 5);
echo "Immediate read: " . $cache->get('price:42') . "\n";
// Simulate expiry by manually advancing the internal clock
// (In a real app, you would just wait 5 seconds)
// We demonstrate by setting a negative TTL entry instead
$cache->set('price:99', 9.99, ttlSeconds: -1); // already expired
echo "Expired read: ";
$result = $cache->get('price:99');
echo ($result === null ? "null (expired)" : $result) . "\n";
echo "Still valid: " . ($cache->has('price:42') ? "yes" : "no") . "\n";
File-Based Persistence
When you need a cache that survives across requests without a dedicated cache server, files work well for small-to-medium workloads. Each cache entry becomes a serialised file on disk. The filename is derived from the key (hashed to keep it filesystem-safe):
<?php
class FileCache {
private string $directory;
public function __construct(string $directory = '/tmp/php_cache') {
$this->directory = $directory;
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
}
private function path(string $key): string {
return $this->directory . '/' . md5($key) . '.cache';
}
public function set(string $key, mixed $value, int $ttlSeconds = 300): void {
$payload = serialize([
'value' => $value,
'expires' => time() + $ttlSeconds,
]);
file_put_contents($this->path($key), $payload, LOCK_EX);
}
public function get(string $key): mixed {
$path = $this->path($key);
if (!file_exists($path)) {
return null;
}
$entry = unserialize(file_get_contents($path));
if (time() > $entry['expires']) {
unlink($path);
return null;
}
return $entry['value'];
}
public function forget(string $key): void {
$path = $this->path($key);
if (file_exists($path)) {
unlink($path);
}
}
}
$cache = new FileCache();
$cacheKey = 'dashboard:stats';
$stats = $cache->get($cacheKey);
if ($stats === null) {
echo "[MISS] Computing stats...\n";
// Simulate heavy computation
$stats = [
'users' => 1482,
'revenue' => 48200.50,
'orders' => 312,
];
$cache->set($cacheKey, $stats, ttlSeconds: 60);
} else {
echo "[HIT] Using cached stats\n";
}
echo "Users: {$stats['users']}\n";
echo "Revenue: \${$stats['revenue']}\n";
echo "Orders: {$stats['orders']}\n";
Cache Invalidation
Storing data is easy. Knowing when to invalidate it is where caching gets hard. The two main strategies are:
- TTL-based: let entries expire automatically after a fixed time
- Event-based: explicitly delete entries when the underlying data changes
Event-based invalidation keeps your cache perfectly consistent but requires discipline. Every write path must also clear related cache keys:
<?php
class InMemoryCache {
private array $store = [];
public function set(string $key, mixed $value): void {
$this->store[$key] = $value;
}
public function get(string $key): mixed {
return $this->store[$key] ?? null;
}
public function forget(string $key): void {
unset($this->store[$key]);
}
public function flush(): void {
$this->store = [];
}
}
class ProductRepository {
private InMemoryCache $cache;
private array $db = [
1 => ['id' => 1, 'name' => 'Widget', 'price' => 19.99],
2 => ['id' => 2, 'name' => 'Gadget', 'price' => 49.99],
];
public function __construct(InMemoryCache $cache) {
$this->cache = $cache;
}
public function find(int $id): ?array {
$key = "product:{$id}";
if (($cached = $this->cache->get($key)) !== null) {
echo " [CACHE HIT] product:{$id}\n";
return $cached;
}
echo " [DB READ] product:{$id}\n";
$product = $this->db[$id] ?? null;
if ($product !== null) {
$this->cache->set($key, $product);
}
return $product;
}
public function update(int $id, array $data): void {
echo " [DB WRITE] Updating product:{$id}\n";
$this->db[$id] = array_merge($this->db[$id] ?? [], $data);
// Invalidate the cached entry immediately
$this->cache->forget("product:{$id}");
echo " [CACHE] Invalidated product:{$id}\n";
}
}
$cache = new InMemoryCache();
$repo = new ProductRepository($cache);
echo "Read #1:\n";
print_r($repo->find(1));
echo "\nRead #2 (cached):\n";
print_r($repo->find(1));
echo "\nUpdate product:\n";
$repo->update(1, ['price' => 24.99]);
echo "\nRead #3 (after invalidation):\n";
print_r($repo->find(1));
The cache is always in sync with the source of truth because every write explicitly clears the stale entry.
Try It Yourself
Build a simple memoization helper that wraps any callable and caches its result by its arguments. This is the function-level equivalent of the repository pattern above:
<?php
function memoize(callable $fn): callable {
$cache = [];
return function () use ($fn, &$cache) {
$args = func_get_args();
$key = serialize($args);
if (!array_key_exists($key, $cache)) {
echo " [COMPUTE] Running with args: " . implode(', ', $args) . "\n";
$cache[$key] = $fn(...$args);
} else {
echo " [MEMO HIT] Returning cached result for: " . implode(', ', $args) . "\n";
}
return $cache[$key];
};
}
// A slow Fibonacci without memoization would be exponential
$fib = memoize(function (int $n) use (&$fib): int {
if ($n <= 1) return $n;
return $fib($n - 1) + $fib($n - 2);
});
echo "Fibonacci sequence:\n";
for ($i = 0; $i <= 7; $i++) {
echo " fib({$i}) = " . $fib($i) . "\n";
}
// Try a different function — currency conversion
$convert = memoize(function (float $amount, string $currency): string {
$rates = ['EUR' => 0.92, 'GBP' => 0.79, 'JPY' => 149.5];
$rate = $rates[$currency] ?? 1.0;
return number_format($amount * $rate, 2) . " {$currency}";
});
echo "\nCurrency conversions:\n";
echo $convert(100, 'EUR') . "\n";
echo $convert(100, 'EUR') . "\n"; // cached
echo $convert(250, 'GBP') . "\n";
echo $convert(250, 'GBP') . "\n"; // cached
Experiment by adding a maximum cache size, or by combining memoization with a TTL so cached results expire after a fixed number of seconds.
Key Takeaways
- Cache-aside is the dominant pattern: check the cache first, fall back to the source of truth on a miss, then populate the cache for next time
- TTL expiration prevents stale data from living forever — choose TTLs based on how often the underlying data changes
- Event-based invalidation gives you real-time consistency at the cost of coupling writes to cache management
- File caches provide request-to-request persistence without external infrastructure — useful for small deployments
- Memoization applies caching at the function level, eliminating redundant computation within a single request
- Cache keys must be deterministic and unique — include every variable that affects the result (user ID, locale, filters)
- Never cache exceptions or errors — a failed result stored in the cache will poison every subsequent request until it expires
Pro Tip: The most dangerous cache bug is a missing invalidation. When you add a new write path to your application — a new admin panel, a background job, an import script — ask yourself: does this write invalidate the right cache keys? Audit your write paths periodically and keep cache key names in a central constant file so they cannot silently diverge between the read and write sides of your code.
Next Steps
You now understand cache-aside patterns, TTL expiration, event-based invalidation, file caching, and function-level memoization. Your applications can serve content faster and handle more load.
You built a basic API back in lesson 12, but production APIs demand more rigor. REST API best practices is the final lesson in the curriculum, and it covers consistent response envelopes, proper HTTP verb usage, meaningful status codes, structured error responses, and URL versioning. These conventions are what separate a professional API from a fragile prototype, and they will serve you whether you are building APIs for your own frontend or for external consumers.