How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)
Short answer: Install predis/predis via Composer, cache json_encode($result) from slow DB queries with $redis->setex($key, $ttl, $value), retrieve with $redis->get($key). A product listing page going from cold MySQL to Redis cache: 800ms → 40ms. The key is cache key design and consistent invalidation.
Install
# Redis server
sudo apt-get install redis-server
sudo service redis-server start
# PHP client — Predis (pure PHP, no extension required)
composer require predis/predis
# Or: phpredis extension (faster, requires PHP extension install)
# In 2015, predis was more common due to shared hosting constraints
Redis Connection Class
<?php
// src/Cache/RedisCache.php
use Predis\Client;
class RedisCache {
private Client $redis;
private string $prefix;
public function __construct(string $prefix = 'app:') {
$this->redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
$this->prefix = $prefix;
}
public function get(string $key): mixed {
$value = $this->redis->get($this->prefix . $key);
return $value !== null ? json_decode($value, true) : null;
}
public function set(string $key, mixed $value, int $ttl = 3600): void {
$this->redis->setex($this->prefix . $key, $ttl, json_encode($value));
}
public function delete(string $key): void {
$this->redis->del($this->prefix . $key);
}
public function deleteByPattern(string $pattern): void {
// Scan instead of KEYS * — non-blocking even on large datasets
$cursor = '0';
do {
[$cursor, $keys] = $this->redis->scan($cursor, [
'match' => $this->prefix . $pattern,
'count' => 100
]);
if (!empty($keys)) {
$this->redis->del($keys);
}
} while ($cursor !== '0');
}
}
Cache Key Strategy
The most important design decision is how you name cache keys. Bad keys lead to cache collisions or inability to invalidate:
<?php
// Cache key conventions we used:
// Pattern: {entity}:{id}:{variant}
// Examples:
// product:42:detail — single product detail page
// product:42:reviews:page:1 — paginated reviews
// category:5:products:sort:price:page:1 — filtered listing
// user:88:cart — user's cart
class CacheKeys {
public static function product(int $id): string {
return "product:{$id}:detail";
}
public static function categoryProducts(int $catId, string $sort, int $page): string {
return "category:{$catId}:products:sort:{$sort}:page:{$page}";
}
public static function categoryProductsPattern(int $catId): string {
return "category:{$catId}:products:*";
// Used with deleteByPattern when category changes
}
public static function userCart(int $userId): string {
return "user:{$userId}:cart";
}
}
Caching a Product Listing Page
<?php
// ProductController.php
class ProductController {
private ProductRepository $repo;
private RedisCache $cache;
public function listing(int $categoryId, string $sort = 'popular', int $page = 1): array {
$cacheKey = CacheKeys::categoryProducts($categoryId, $sort, $page);
// Try cache first
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return $cached; // ~0.3ms Redis read vs ~800ms MySQL query
}
// Cache miss: query database
$products = $this->repo->getByCategory($categoryId, $sort, $page);
$total = $this->repo->countByCategory($categoryId);
$result = [
'products' => $products,
'total' => $total,
'page' => $page,
];
// Cache for 10 minutes
// TTL choice: listing pages change rarely but product stock changes often
// 10 min = acceptable staleness for price/stock display
$this->cache->set($cacheKey, $result, 600);
return $result;
}
}
Cache Invalidation on Write
<?php
// ProductService.php — always invalidate after writes
class ProductService {
private RedisCache $cache;
public function updateProduct(int $id, array $data): void {
// Update in database first
$this->productRepo->update($id, $data);
// Invalidate specific product cache
$this->cache->delete(CacheKeys::product($id));
// Invalidate all listing pages containing this product
$product = $this->productRepo->find($id);
$this->cache->deleteByPattern(
CacheKeys::categoryProductsPattern($product['category_id'])
);
}
public function updateStock(int $productId, int $newQty): void {
$this->productRepo->updateStock($productId, $newQty);
// Stock changes need immediate cache invalidation
$this->cache->delete(CacheKeys::product($productId));
// Don't invalidate listing pages for stock —
// "in stock" status updates on next listing page TTL
}
}
Stampede Prevention (Cache Dog-Pile)
When cache expires and 50 concurrent requests all query the DB simultaneously:
<?php
// Probabilistic early expiration (2015 technique before "cache aside" libraries)
class AntiStampedeCache {
private RedisCache $cache;
/**
* Regenerate cache slightly before expiry using probability
* Based on XFetch algorithm
*/
public function getWithAntiStampede(string $key, callable $fetchFn, int $ttl = 600): mixed {
$cacheEntry = $this->cache->getRaw($key); // Returns [value, stored_at, ttl]
if ($cacheEntry !== null) {
$age = time() - $cacheEntry['stored_at'];
$remaining = $cacheEntry['ttl'] - $age;
// Probabilistically refresh when less than 20% TTL remains
// More concurrent requests = higher probability of early refresh
if ($remaining > 0 && $remaining < $ttl * 0.2) {
$rand = mt_rand() / mt_getrandmax();
if ($rand < 0.1) { // 10% chance to refresh early
goto fetch;
}
}
return $cacheEntry['value'];
}
fetch:
$value = $fetchFn();
$this->cache->set($key, $value, $ttl);
return $value;
}
}
// Simpler alternative: mutex lock
public function getWithLock(string $key, callable $fetchFn, int $ttl): mixed {
$value = $this->cache->get($key);
if ($value !== null) return $value;
$lockKey = "lock:{$key}";
$locked = $this->redis->set($lockKey, '1', 'EX', 10, 'NX'); // SET NX EX
if (!$locked) {
// Another process is fetching — wait briefly and retry
usleep(200000); // 200ms
return $this->cache->get($key) ?? $fetchFn();
}
$value = $fetchFn();
$this->cache->set($key, $value, $ttl);
$this->redis->del($lockKey);
return $value;
}
Cache Warming on Deploy
<?php
// scripts/warm_cache.php — run after deploy before traffic hits
// Pre-populate top 100 product pages
$topCategories = $db->query("SELECT id FROM categories ORDER BY product_count DESC LIMIT 20")->fetchAll();
foreach ($topCategories as $cat) {
foreach (['popular', 'price_asc', 'price_desc'] as $sort) {
for ($page = 1; $page <= 5; $page++) {
$controller->listing($cat['id'], $sort, $page);
echo "Warmed: category/{$cat['id']} sort={$sort} page={$page}\n";
usleep(50000); // 50ms between requests to not overwhelm DB
}
}
}
echo "Cache warming complete\n";
Redis Memory Management
# redis.conf settings we used in production
maxmemory 512mb
maxmemory-policy allkeys-lru
# LRU eviction: when memory full, remove least recently used keys
# For a pure cache (not session store), allkeys-lru is correct
# For session store: volatile-lru (only evict keys with TTL)
# Monitor memory
redis-cli INFO memory | grep used_memory_human
redis-cli INFO stats | grep evicted_keys
# evicted_keys > 0 means memory is too small
Measured Results
Product listing page with 20 products, MySQL with no indexes issues (legacy schema):
| Scenario | Response time | DB queries |
|---|---|---|
| Cold (no cache) | 820ms | 3 queries |
| Cache hit | 41ms | 0 queries |
| Cache hit rate (steady state) | 94% | — |
After 2 weeks, 94% of listing requests were served from cache. The 6% were cache misses on new or freshly-invalidated pages. DB load dropped by ~85%.