AboutBlogContact
Backend EngineeringJuly 30, 2015 6 min read 192Updated: June 22, 2026

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

AunimedaAunimeda
📋 Table of Contents

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


Aunimeda builds production-grade backend systems - APIs, microservices, real-time applications, and system integrations.

Contact us for backend engineering services. See also: Custom Software Development, Web Development

Read Also

PostgreSQL Performance Optimization: The Practical Guide for 2026aunimeda
Backend Engineering

PostgreSQL Performance Optimization: The Practical Guide for 2026

Slow queries, missing indexes, N+1 problems, and connection pool exhaustion account for 90% of PostgreSQL performance issues. Here's how to diagnose and fix each one with real queries.

Redis Data Structures in Production: Beyond SET and GETaunimeda
Backend Engineering

Redis Data Structures in Production: Beyond SET and GET

Most teams use Redis as a glorified hash map. This guide covers the data structures that solve real production problems - sorted sets for leaderboards, streams for durable event queues, HyperLogLog for UV counting at scale, and Lua scripts for atomic operations you can't otherwise do safely.

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

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.

Need IT development for your business?

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

Get Consultation All articles