Santaji GadePHP, Development14 hours ago11 Views

A practical guide to caching database queries in PHP with Redis, covering cache aside, TTL strategy, invalidation on write, and stampede protection.
Table of Contents
ToggleA database query that takes fifty milliseconds feels instant once. It feels very different once a thousand visitors trigger it every minute. Caching database queries in PHP with Redis is how that same query starts answering in under a millisecond on every repeat.
Every database query pays for a network round trip, a query plan, and disk or buffer pool access, even for data that barely changes.
Redis keeps that same data in memory on a server built for one job: answering key lookups as fast as the network allows. Memcached is the other well known option here, though Redis's richer data structures make it the more common default for new projects today.
The pattern below, cache aside, is the simplest way to put that speed in front of an existing MySQL or PostgreSQL query without rewriting how the application talks to its database.
The connection setup is the same phpredis extension and pattern covered earlier in this series for rate limiting, just pointed at a different set of keys. Predis is a pure PHP alternative worth knowing about when the phpredis C extension cannot be installed on a shared host.
Not every query is worth caching. A value read often and written rarely, like a user profile or a product listing, is a good candidate. A value that changes on every request is not.
Redis's own reference for SETEX documents exactly how the expiry argument used throughout this article behaves at the command level.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_PREFIX, 'cache:');
Cache aside checks Redis first. On a miss, it runs the real query, stores the result, and returns it. On a hit, the database is never touched.
function get_user_cached(Redis $redis, PDO $pdo, int $userId): array {
$key = "user:$userId";
$cached = $redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
$stmt = $pdo->prepare('SELECT id, name, plan FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$redis->setex($key, 300, json_encode($user));
return $user;
}
PHP's own PDO prepared statements documentation covers the parameter binding used above, which stays exactly the same whether or not a cache sits in front of it.
Running this twice in a row, once cold and once warm, against a query deliberately slowed down to simulate real database latency, shows exactly what that cache hit is worth.
Actual timing output from running the cache aside function cold and then warm against the same key.
That gap only grows under real traffic, since every one of those repeat reads that used to hit the database now costs Redis a fraction of a millisecond instead.
| Strategy | When It Writes to Cache | Best For |
|---|---|---|
| Cache aside | On a cache miss, lazily | Read heavy data with occasional writes |
| Read through | Automatically inside the cache layer | Apps that want caching hidden from query code |
| Write through | On every write, immediately | Data that must never serve stale on a read |
A single global expiry treats a rarely changing setting the same as a price that updates hourly, which is rarely the right tradeoff for either one.
$ttlSeconds = [
'user_profile' => 3600,
'product_price' => 60,
'site_setting' => 86400,
];
$redis->setex($key, $ttlSeconds[$dataType], $payload);
A shorter TTL means more database reads but fresher data. A longer TTL means fewer reads but a longer window where a change is not yet visible. AWS's own caching best practices guide walks through this exact tradeoff in more depth, independent of which cache server is running behind it.
A TTL alone means every update waits out the clock before the cache catches up. Explicit invalidation on write removes that wait entirely.
function update_user_plan(Redis $redis, PDO $pdo, int $userId, string $newPlan): void {
$stmt = $pdo->prepare('UPDATE users SET plan = ? WHERE id = ?');
$stmt->execute([$newPlan, $userId]);
$redis->del("user:$userId");
}
The next read after that delete finds nothing in Redis, falls back to the database automatically, and quietly repopulates the cache with the fresh value.
Actual output confirming a write invalidates the stale key and the following read repopulates it.
When a hot key expires, every request arriving in that same instant can miss the cache together and hit the database all at once.
A short lived lock lets one request rebuild the value while the rest wait briefly and then read the freshly cached result instead of repeating the same query.
$lockKey = "lock:$key";
if ($redis->set($lockKey, 1, ['nx', 'ex' => 5])) {
$fresh = run_expensive_query();
$redis->setex($key, 300, json_encode($fresh));
$redis->del($lockKey);
} else {
usleep(50000);
return get_user_cached($redis, $pdo, $userId);
}
Only the request that wins the lock ever reaches the database. Every other request briefly waits instead of piling on with a duplicate query. Cloudflare's overview of caching covers this same stampede problem at the edge network layer, where the same lock and wait idea shows up under a different name.
Redis tracks its own hit and miss counts, which is the fastest way to confirm a cache is doing real work rather than sitting mostly empty.
# check the running hit ratio directly from Redis
redis-cli info stats | grep keyspace
A hit ratio that stays low after normal traffic usually means the TTL is too short, the wrong queries were chosen for caching, or invalidation is firing more often than it should.
Cache aside is the simplest starting point. Check Redis first, run the real query only on a miss, then store the result before returning it.
It depends entirely on how stale the data can safely be. A rarely changing setting can sit for a day, while a price should expire in minutes.
A TTL alone leaves a window where an update is not yet visible. Deleting the key on write removes that window immediately.
A popular key expiring lets many requests miss at once. A short lived lock lets one request rebuild it while the rest wait briefly instead of piling on.
Check whether the TTL is too short, whether the wrong queries were chosen for caching, or whether invalidation is firing more often than the data actually changes.
Check Redis first, query the database only on a miss.
Read heavy, rarely changing data benefits the most.
A setting and a price should never share one expiry.
A delete on update removes stale reads immediately.
One request rebuilds a hot key while others wait briefly.
A low ratio after normal traffic signals a tuning problem.
Caching database queries in PHP with Redis pairs naturally with the rate limiting and validation work covered elsewhere in this series.









