{
  "id": "php",
  "title": "Redis cache-aside with Predis",
  "url": "https://redis.io/docs/latest/develop/use-cases/cache-aside/php/",
  "summary": "Implement a Redis cache-aside layer in PHP with Predis",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-05-12T09:07:59-04:00",
  "children": [],
  "page_type": "content",
  "content_hash": "a81ee8c19a91b9e6b735cd1c9f1a74c26bb5ea6a580a6414fcd65e4abc8a2650",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to implement a Redis cache-aside layer in PHP with the [Predis](https://github.com/predis/predis) client library. It includes a small local web server built on PHP's built-in development server so you can see cache hits, misses, invalidation on write, and stampede protection in action."
    },
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Cache-aside is one of the most common Redis use cases for read-heavy applications. Instead of querying the primary database on every request, the application checks Redis first and only falls back to the primary on a miss. The result is written back to Redis with a TTL so the next read is served from memory.\n\nThat gives you:\n\n* Sub-millisecond reads for the hot working set\n* Bounded staleness — every entry expires within a known window\n* Reduced primary database load proportional to hit rate\n* Field-level updates without re-serializing the full record\n* Protection against cache stampedes when popular keys expire under load\n\nIn this example, each cached product is stored as a Redis hash under a key like `cache:product:{id}`. The hash holds the product fields (`id`, `name`, `price_cents`, `stock`) and the key has a TTL so stale data is bounded automatically."
    },
    {
      "id": "how-it-works",
      "title": "How it works",
      "role": "content",
      "text": "The flow on every read looks like this:\n\n1. The application calls `$cache->get($productId, fn($id) => $primary->read($id))`\n2. The helper runs `HGETALL` against `cache:product:{id}`\n3. On a hit, the cached hash is returned directly\n4. On a miss, the helper acquires a Lua-backed single-flight lock and invokes the loader to fetch from the primary\n5. The helper writes the result back to Redis with `HSET` plus `EXPIRE` and releases the lock\n6. Concurrent worker processes that fail to acquire the lock wait briefly for the cache to populate, then return that value instead of issuing their own primary read\n\nOn a write, the application updates the primary and then deletes the cache key, so the next read repopulates from the new source value."
    },
    {
      "id": "state-across-stateless-requests",
      "title": "State across stateless requests",
      "role": "content",
      "text": "PHP requests do not share process memory, so the demo keeps everything that needs to persist between requests in Redis itself:\n\n* Hit, miss, and stampede counters live in the `demo:cache_stats` hash, incremented atomically with `HINCRBY`.\n* The mock primary's read counter and per-record state live under `demo:primary:*` keys.\n\nThis is a cache-aside pattern detail rather than a Redis one — the same helper would use process-local counters in a long-running PHP-FPM or Roadrunner deployment, but the public method surface stays the same."
    },
    {
      "id": "the-cache-aside-helper",
      "title": "The cache-aside helper",
      "role": "content",
      "text": "The `RedisCache` class wraps the cache-aside operations\n([source](Cache.php)):\n\n[code example]"
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each cached product is stored in a Redis hash:\n\n[code example]\n\nThe implementation uses:\n\n* [`HGETALL`](https://redis.io/docs/latest/commands/hgetall) to read the cached record\n* [`HMSET`](https://redis.io/docs/latest/commands/hmset) plus [`EXPIRE`](https://redis.io/docs/latest/commands/expire) to repopulate after a miss\n* [`DEL`](https://redis.io/docs/latest/commands/del) to invalidate on writes\n* [`TTL`](https://redis.io/docs/latest/commands/ttl) to surface remaining staleness in the demo UI\n* [`EVAL`](https://redis.io/docs/latest/commands/eval) for the Lua single-flight lock that prevents stampedes\n* [`WATCH`](https://redis.io/docs/latest/commands/watch)/[`MULTI`](https://redis.io/docs/latest/commands/multi)/[`EXEC`](https://redis.io/docs/latest/commands/exec) for the conditional field update path\n* [`HINCRBY`](https://redis.io/docs/latest/commands/hincrby) for the cross-request hit/miss counters"
    },
    {
      "id": "cache-aside-reads",
      "title": "Cache-aside reads",
      "role": "content",
      "text": "The `get()` method runs `HGETALL` on the cache key first. On a hit it returns the cached hash and increments the hit counter. On a miss, it delegates to a single-flight loader:\n\n[code example]\n\nThe returned array includes the measured Redis round-trip time so the demo UI can show the latency difference between a hit and a miss."
    },
    {
      "id": "stampede-protection-with-a-lua-lock",
      "title": "Stampede protection with a Lua lock",
      "role": "content",
      "text": "When a popular key expires, every concurrent reader observes the miss at the same instant. Without coordination, all of them would query the primary and overwrite the cache redundantly — a *cache stampede*.\n\nThe helper uses a tiny Lua script to acquire a short-lived lock atomically. Only the caller that wins the `SET NX` becomes the primary loader; the rest poll the cache briefly and return the value the lock holder writes:\n\n[code example]\n\nA second script releases the lock only if the caller still owns it, so a lock that timed out and was re-acquired by someone else cannot be released by mistake:\n\n[code example]\n\nThe PHP side runs both scripts via `EVAL` on every miss:\n\n[code example]\n\nThe unique `token` per caller is what makes the release script safe — only the caller that actually holds the lock can release it."
    },
    {
      "id": "invalidation-on-write",
      "title": "Invalidation on write",
      "role": "content",
      "text": "When a write hits the primary, the application invalidates the cache key. The next read pulls fresh data from the primary:\n\n[code example]\n\nThis is the simplest and safest pattern: never try to keep the cache and primary in sync directly, just delete the cache entry and let the next read repopulate it."
    },
    {
      "id": "field-level-updates",
      "title": "Field-level updates",
      "role": "content",
      "text": "Because each record is stored as a hash, the cache helper can also update a single field in place without re-serializing the full record. The update only writes if the entry is already cached, so a partial record can never appear in Redis:\n\n[code example]\n\nThis is useful for hot fields that change more often than the rest of the record (a stock counter, a view count) and would otherwise force a full reload."
    },
    {
      "id": "prerequisites",
      "title": "Prerequisites",
      "role": "content",
      "text": "Before running the demo, make sure that:\n\n* Redis is running and accessible. By default, the demo connects to `localhost:6379`.\n* PHP 8.1 or later is installed.\n* The Predis library is installed via Composer:\n\n[code example]\n\nIf your Redis server is running elsewhere, set `REDIS_HOST` and `REDIS_PORT` in the environment before starting the demo."
    },
    {
      "id": "running-the-demo",
      "title": "Running the demo",
      "role": "content",
      "text": "A local demo server is included to show the cache-aside layer in action\n([source](demo_server.php)):\n\n[code example]\n\nThe demo server uses PHP's built-in development server plus a separate worker script (`stampede_worker.php`) for the concurrent stampede test. The PHP CLI server is single-threaded, so the demo launches independent PHP processes via `proc_open` to get genuine parallelism for that test.\n\nIt exposes a small interactive page where you can:\n\n* Read a product through the cache and see whether it was a hit or a miss\n* Compare the measured Redis round-trip against the simulated primary read latency\n* Watch the cache TTL count down between requests\n* Update a field on the primary and see the cache invalidate automatically\n* Run a stampede test that spawns N concurrent worker processes against a freshly-invalidated key and confirms only one of them reaches the primary\n* Reset the hit/miss counters at any time\n\nAfter starting the server, visit `http://localhost:8080`."
    },
    {
      "id": "the-mock-primary-store",
      "title": "The mock primary store",
      "role": "content",
      "text": "To make the demo self-contained, the example includes a `MockPrimaryStore` that stands in for a slow disk-backed database\n([source](Primary.php)):\n\n[code example]\n\nEvery call to `read()` sleeps for `$readLatencyMs` so the difference between a cache hit and a miss is obvious in the UI. Records are seeded into Redis under `demo:primary:product:{id}` so updates and the read counter survive between requests.\n\nFor the stampede test, an N-worker burst against a cold key should produce exactly one increment of `demo:primary:reads`, confirming that single-flight is working.\n\nIn a real application this would be replaced by a SQL query, an HTTP call to a downstream service, or any other slow-but-authoritative source."
    },
    {
      "id": "production-usage",
      "title": "Production usage",
      "role": "content",
      "text": "This guide uses a deliberately small local demo so you can focus on the cache-aside pattern. In production, you will usually want to harden several aspects of it."
    },
    {
      "id": "choose-a-ttl-that-matches-your-staleness-tolerance",
      "title": "Choose a TTL that matches your staleness tolerance",
      "role": "content",
      "text": "The TTL is the upper bound on how long a stale value can be served. Shorter TTLs mean lower hit rates and more primary load; longer TTLs mean higher hit rates and more stale reads between writes. Pick the value that matches your business tolerance for stale data, and combine it with explicit invalidation on writes for the cases where you cannot tolerate any staleness."
    },
    {
      "id": "invalidate-don-t-try-to-keep-the-cache-in-sync",
      "title": "Invalidate, don't try to keep the cache in sync",
      "role": "content",
      "text": "When the underlying record changes, delete the cache key rather than rewriting it. Cache-aside is robust precisely because it never assumes the cache holds the latest value — the next read always re-fetches from the primary on a miss."
    },
    {
      "id": "handle-missing-records-explicitly",
      "title": "Handle missing records explicitly",
      "role": "content",
      "text": "In this demo, a missing record returns `null` and nothing is cached. In a real system you may want to cache \"not found\" sentinels with a short TTL to absorb load from probing for non-existent IDs, while making sure the sentinel TTL is shorter than the positive cache entry so a newly-created record becomes visible quickly."
    },
    {
      "id": "tune-the-single-flight-lock-ttl",
      "title": "Tune the single-flight lock TTL",
      "role": "content",
      "text": "The lock TTL needs to be longer than the worst-case primary read latency so a slow loader does not lose the lock midway. The unique token in `RELEASE_LOCK_SCRIPT` ensures the original caller does not delete someone else's lock if its lock has expired."
    },
    {
      "id": "use-a-long-running-php-runtime-in-production",
      "title": "Use a long-running PHP runtime in production",
      "role": "content",
      "text": "The PHP built-in server used here is fine for local development, but in production you will typically run PHP under PHP-FPM, Apache mod_php, FrankenPHP, or Roadrunner. The cache-aside helper code is unchanged, but you can move the hit/miss counters into shared memory (APCu, Roadrunner KV) instead of Redis if you want to avoid the per-request increment."
    },
    {
      "id": "namespace-cache-keys-in-shared-redis-deployments",
      "title": "Namespace cache keys in shared Redis deployments",
      "role": "content",
      "text": "If multiple applications share a Redis deployment, prefix cache keys with the application name (`cache:billing:product:{id}`) so different services cannot clobber each other's entries."
    },
    {
      "id": "inspect-cached-entries-directly-in-redis",
      "title": "Inspect cached entries directly in Redis",
      "role": "content",
      "text": "When testing or troubleshooting, inspect the stored cache key directly to confirm the application is writing the fields and TTL you expect:\n\n[code example]"
    },
    {
      "id": "learn-more",
      "title": "Learn more",
      "role": "related",
      "text": "* [Predis on GitHub](https://github.com/predis/predis) - Install and use the PHP Redis client\n* [SET command](https://redis.io/docs/latest/commands/set) - Set a string with TTL options (`EX`, `PX`, `NX`)\n* [HSET command](https://redis.io/docs/latest/commands/hset) - Write hash fields\n* [HGETALL command](https://redis.io/docs/latest/commands/hgetall) - Read every field of a hash\n* [EXPIRE command](https://redis.io/docs/latest/commands/expire) - Set key expiration in seconds\n* [DEL command](https://redis.io/docs/latest/commands/del) - Delete a key on invalidation\n* [Lua scripting](https://redis.io/docs/latest/develop/programmability/eval-intro) - Atomic single-flight locks and stampede mitigation"
    }
  ],
  "examples": [
    {
      "id": "the-cache-aside-helper-ex0",
      "language": "php",
      "code": "require_once 'vendor/autoload.php';\nrequire_once 'Cache.php';\nrequire_once 'Primary.php';\n\nuse Predis\\Client as PredisClient;\n\n$redis = new PredisClient(['host' => 'localhost', 'port' => 6379]);\n$primary = new MockPrimaryStore($redis, readLatencyMs: 150);\n$cache = new RedisCache($redis, ttl: 30);\n\n// Read through the cache.\n$result = $cache->get('p-001', fn(string $id) => $primary->read($id));\necho \"hit={$result['hit']} latency={$result['redis_latency_ms']} ms\\n\";\n\n// Update a single field without rewriting the whole record.\n$cache->updateField('p-001', 'stock', '41');\n\n// Invalidate the cache key on a write to the primary.\n$primary->updateField('p-001', 'price_cents', '699');\n$cache->invalidate('p-001');",
      "section_id": "the-cache-aside-helper"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "cache:product:p-001\n  id          = p-001\n  name        = Sourdough Loaf\n  price_cents = 650\n  stock       = 42",
      "section_id": "data-model"
    },
    {
      "id": "cache-aside-reads-ex0",
      "language": "php",
      "code": "public function get(string $entityId, callable $loader): array\n{\n    $cacheKey = $this->cacheKey($entityId);\n\n    $started = self::monotonicMs();\n    $cached = $this->redis->hgetall($cacheKey);\n    $redisLatencyMs = self::monotonicMs() - $started;\n\n    if (is_array($cached) && count($cached) > 0) {\n        $this->recordHit();\n        return ['record' => $cached, 'hit' => true, 'redis_latency_ms' => $redisLatencyMs];\n    }\n\n    $this->recordMiss();\n    $record = $this->loadWithSingleFlight($entityId, $loader);\n    return ['record' => $record, 'hit' => false, 'redis_latency_ms' => $redisLatencyMs];\n}",
      "section_id": "cache-aside-reads"
    },
    {
      "id": "stampede-protection-with-a-lua-lock-ex0",
      "language": "lua",
      "code": "-- Acquire a short-lived lock with SET NX PX. Returns 1 on acquire, 0 otherwise.\nif redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then\n    return 1\nend\nreturn 0",
      "section_id": "stampede-protection-with-a-lua-lock"
    },
    {
      "id": "stampede-protection-with-a-lua-lock-ex1",
      "language": "lua",
      "code": "if redis.call('GET', KEYS[1]) == ARGV[1] then\n    return redis.call('DEL', KEYS[1])\nend\nreturn 0",
      "section_id": "stampede-protection-with-a-lua-lock"
    },
    {
      "id": "stampede-protection-with-a-lua-lock-ex2",
      "language": "php",
      "code": "private function loadWithSingleFlight(string $entityId, callable $loader): ?array\n{\n    $cacheKey = $this->cacheKey($entityId);\n    $lockKey = $this->lockKey($entityId);\n    $token = bin2hex(random_bytes(8));\n\n    $acquired = (int) $this->redis->eval(\n        self::ACQUIRE_LOCK_SCRIPT, 1, $lockKey, $token, (string) $this->lockTtlMs\n    );\n\n    if ($acquired === 1) {\n        try {\n            $record = $loader($entityId);\n            if ($record === null) return null;\n            $this->redis->transaction(function ($pipe) use ($cacheKey, $record): void {\n                $pipe->del([$cacheKey]);\n                $pipe->hmset($cacheKey, $record);\n                $pipe->expire($cacheKey, $this->ttl);\n            });\n            return $record;\n        } finally {\n            $this->redis->eval(self::RELEASE_LOCK_SCRIPT, 1, $lockKey, $token);\n        }\n    }\n\n    $this->recordStampedeSuppressed();\n    $deadline = self::monotonicMs() + $this->lockTtlMs;\n    while (self::monotonicMs() < $deadline) {\n        usleep($this->waitPollMs * 1000);\n        $cached = $this->redis->hgetall($cacheKey);\n        if (is_array($cached) && count($cached) > 0) return $cached;\n    }\n    return $loader($entityId);\n}",
      "section_id": "stampede-protection-with-a-lua-lock"
    },
    {
      "id": "invalidation-on-write-ex0",
      "language": "php",
      "code": "public function invalidate(string $entityId): bool\n{\n    return ((int) $this->redis->del([$this->cacheKey($entityId)])) === 1;\n}",
      "section_id": "invalidation-on-write"
    },
    {
      "id": "field-level-updates-ex0",
      "language": "php",
      "code": "public function updateField(string $entityId, string $field, string $value): bool\n{\n    $cacheKey = $this->cacheKey($entityId);\n    while (true) {\n        $this->redis->watch($cacheKey);\n        if ((int) $this->redis->exists($cacheKey) === 0) {\n            $this->redis->unwatch();\n            return false;\n        }\n        $result = $this->redis->transaction(function ($pipe) use ($cacheKey, $field, $value): void {\n            $pipe->hset($cacheKey, $field, $value);\n            $pipe->expire($cacheKey, $this->ttl);\n        });\n        if ($result !== null) {\n            return true;\n        }\n        // null means WATCH detected a change — retry.\n    }\n}",
      "section_id": "field-level-updates"
    },
    {
      "id": "prerequisites-ex0",
      "language": "bash",
      "code": "composer require predis/predis",
      "section_id": "prerequisites"
    },
    {
      "id": "running-the-demo-ex0",
      "language": "bash",
      "code": "php -S localhost:8080 demo_server.php",
      "section_id": "running-the-demo"
    },
    {
      "id": "the-mock-primary-store-ex0",
      "language": "php",
      "code": "public function read(string $id): ?array\n{\n    usleep($this->readLatencyMs * 1000);\n    $this->redis->incr(self::READS_KEY);\n    $record = $this->redis->hgetall(self::RECORDS_KEY_PREFIX . $id);\n    return is_array($record) && count($record) > 0 ? $record : null;\n}",
      "section_id": "the-mock-primary-store"
    },
    {
      "id": "inspect-cached-entries-directly-in-redis-ex0",
      "language": "bash",
      "code": "redis-cli HGETALL cache:product:p-001\nredis-cli TTL cache:product:p-001",
      "section_id": "inspect-cached-entries-directly-in-redis"
    }
  ]
}
