AboutBlogContact
BackendJuly 30, 2015 5 min read 37

How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)

AunimedaAunimeda
📋 Table of Contents

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%.

Read Also

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)aunimeda
Backend

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)

Socket.io 1.x (2016) made WebSockets practical: automatic fallback to polling, rooms for namespacing, and a simple emit/on API. We used it to build a real-time order tracking dashboard. Here's the server setup, client integration, authentication via JWT, and scaling with Redis adapter.

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)aunimeda
Backend

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)

MySQL LIKE queries break at scale. When our product catalog reached 200k items, search took 4+ seconds. Elasticsearch 2.x solved it: 50ms search across 200k documents with relevance scoring, typo tolerance, and faceted filters. Here's the indexing strategy, mapping, and PHP/Node.js integration.

How to Implement JWT Authentication in Node.js + Express (2015)aunimeda
Backend

How to Implement JWT Authentication in Node.js + Express (2015)

JWT became the standard for stateless API authentication in 2015 as mobile apps replaced session cookies. This guide covers the exact implementation: token generation, middleware, refresh tokens, and the security mistakes we made and fixed.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles