Skip to content
intermediate Phase 14 · Queues, Caching, and Background Jobs

Caching with Redis and Files

Store expensive results in a fast key-value layer.

40m
0 problems
Topic Progress 0%

Cache::remember on a 400 ms Query: Cache-Aside in Practice

A product page runs one aggregate query plus three HTTP calls to the inventory service: 400 ms per view, every view. The second visitor pays the same 400 ms as the first. Cache-aside fixes this without changing a single table, and Laravel hands it to you as one method:

$product = Cache::remember(
    "product:{$product->id}:{$locale}",       // key
    now()->addMinutes(10),                    // TTL — never omit it
    fn () => $this->buildProductView($product, $locale)   // only runs on a miss
);

remember() is cache-aside (lazy population): the application checks the cache first; on a miss it computes the value itself and writes it back. The cache store is dumb on purpose — it never fetches, it only holds. That inversion is why cache-aside works with any backend and why it is the pattern Laravel uses everywhere (routes, config, and views are all lazily populated caches).

The alternatives exist because cache-aside has a known weakness — the stampede on a cold key, which Cache Invalidation Strategies handles:

Pattern Who fills the cache Write cost Main risk
Cache-aside application, on read miss writes hit DB only every miss pays full price; thundering herd
Write-through application writes cache and DB together write = both stores coherent reads, but writes slow down
Write-behind async flush later fast writes you can lose the buffered writes
Read-through the cache's own loader read = one call ties you to the store's API

Write-through buys read consistency — a read right after a write is never stale — at the price of making every write do double work. For a read-heavy catalog, cache-aside with a bounded TTL wins; for a counter that must never lose an increment, write-through (or no cache) is honest.

Pick the store per environment, not per fashion:

CACHE_STORE=redis          # .env — config/cache.php defines each store
php artisan cache:table      # migration for the database store
php artisan cache:clear      # nuclear: forget every key in the default store
php artisan config:cache     # separate concept — compiles config to one file
Store Latency Shares across servers Atomic ops / tags / locks Use it for
file disk (~1 ms+) no — each server has its own copy no single-box dev
database 1–5 ms + row locks yes limited low traffic, no Redis yet
redis sub-ms over network yes yes — all three production default
memcached sub-ms yes limited simple RAM cache with size eviction
apcu ~1 µs, in-process no no per-server opcode-adjacent caches
array free no, per-request no tests

The one thing that matters: a cache key must contain every input that changes the output. product:42 is wrong if the response varies by locale, currency, or logged-in tier — write product:42:en-GB:guest. Miss the variant and one visitor's price gets served to everyone.

Two costs hide behind remember(). Serialization: values are PHP-serialize()d (JSON for Redis with the right options), so caching a 2 MB collection moves the cost from query to network. And the miss path runs synchronously inside the request — a cold cache on a busy morning makes every request pay full price at once, which is exactly the failure mode the next two topics exist to prevent.

Why Redis Also Holds Your Sessions and Queues

One Redis server ended up holding your cache, your sessions, and your queue payloads. That is not an accident of configuration — it is because Redis does three things those workloads need and a file cannot: sub-millisecond reads over the network, atomic operations on a single-threaded event loop, and persistence that survives a restart.

Compare the mechanics rather than the marketing:

file driver    → open() + read() + unserialize() on local disk; per-server copy
APCu           → shared memory inside one PHP-FPM machine; ~1 µs; gone on restart
Redis          → one TCP round trip (~0.2–0.5 ms in the same AZ) to one shared dataset

A file driver stores each key as a file under storage/framework/cache/data. It costs a syscall and a disk read per hit, it cannot do atomic compare-and-set, and — the killer in any deployment with more than one web server — each server has its own copy. web01 invalidates product:42, web02 keeps serving the stale file for the rest of its TTL. APCu avoids the disk but has the same per-server boundary, which is why it is excellent for compiled routes and config and wrong for anything a second machine must observe.

Redis persistence is why sessions and queues live there too. With RDB snapshots or AOF append-only logging enabled, a Redis restart does not log every user out and does not drop queued jobs:

redis-cli ping                                   # PONG
redis-cli info memory | grep -E 'used_memory_human|maxmemory'
redis-cli info persistence | grep -E 'rdb_last_bgsave_status|aof_enabled'
redis-cli ttl "myapp:cache:product:42"           # seconds remaining, -1 = no expiry
redis-cli --scan --pattern 'myapp:cache:*' | head   # KEYS * blocks the server — never use it

Eviction policy is the setting people skip and regret. Under maxmemory 2gb with maxmemory-policy noeviction, Redis refuses writes the moment it fills up — your cache starts returning errors. allkeys-lru quietly evicts the least recently used keys, which is fine for pure cache and catastrophic if it starts evicting session or queue keys. Production setups keep cache and queue data separated:

// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'cache' => ['url' => env('REDIS_URL'), 'host' => ..., 'database' => env('REDIS_CACHE_DB', 1)],
    'queue' => ['host' => ..., 'database' => env('REDIS_QUEUE_DB', 2)],
    'sessions' => ['host' => ..., 'database' => env('REDIS_SESSION_DB', 3)],
],

Distinct databases (or at minimum distinct key prefixes — CACHE_PREFIX, QUEUE_PREFIX, SESSION_PREFIX) mean php artisan cache:clear can never flush your queue, and an allkeys-lru policy on the cache DB cannot evict a half-processed job.

Because Redis executes one command at a time, compound actions become atomic — the property neither file nor APCu gives you:

// rate limit: INCR is atomic, so 100 concurrent requests cannot exceed the cap
$hits = Cache::increment("rl:login:{$_SERVER['REMOTE_ADDR']}");
if ($hits === 1) Cache::put("rl:login:{$_SERVER['REMOTE_ADDR']}", 1, now()->addMinute());
if ($hits > 100) abort(429);

// cross-server mutex — needs a lock-capable store (redis, database, file, array, memcached)
$lock = Cache::lock('report:monthly', 30);
$lock->block(5, fn () => $this->buildReport());   // waits up to 5s for the lock

Cache::lock() is the primitive the next topic leans on for both stampede protection and scheduled-task overlap guards. If a store cannot lock, those features fail loudly at runtime — which is the interview follow-up: name one capability a file cache cannot give you, and say why it matters.

One more distinction saves confusion in code review: Redis-as-cache and Redis-as-broker use different data structures. Cache entries are plain string keys with a TTL (SET myapp:cache:product:42 ... EX 600); queue payloads are pushed onto lists and popped with blocking reads. Same server, same memory budget, completely different operational expectations — which is exactly why the next chapter separates cache correctness from cache capacity.

Tuning the Cache in Production: Eviction, Cold Starts, and Store Swaps

Swapping CACHE_STORE from file to redis is a one-line change that silently alters semantics. Before you ship it, know the three things that actually break in production: the cold-start cliff, memory eviction, and keys that outlive their owners.

The cold-start cliff

A cache miss is not free — it costs the full compute path plus the write. Measure the hit rate, or you are tuning blind:

redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'
# hit rate = hits / (hits + misses) — below ~90% on a read-heavy page, the key design is wrong
php artisan cache:clear           # deploys that run this create a guaranteed stampede window

Two habits remove most of the pain. Warm critical keys during deployment instead of clearing everything — prefer versioned keys (product:v42:42) so old entries age out by TTL while new ones are built by a background job. And bound every miss cost: if buildProductView() takes 400 ms, at least make sure the miss path is what a queue job does, not what your p99 does.

TTLs, prefixes, and orphan keys

Cache::put("cart:{$userId}", $items, now()->addHours(2));   // explicit expiry, always
Cache::forever('feature-flags', $flags);                     // deliberate: config-like data

A missing TTL means an entry that lives until someone runs cache:clear — carts, counters, and rate-limit keys accumulate until Redis hits maxmemory. Conversely, a TTL shorter than the data's real validity hands you stale reads. The rule that settles both: set the TTL to the longest staleness you can defend in an incident, then make the key carry every variant input (locale, currency, user tier) so a stale-but-correct value is still correct.

What changes when you switch stores

Concern file / apcu database redis
Shared across web servers no — stale copies yes yes
Cache::lock() / atomic ops apcu: no; file: no partial full
Tag invalidation file: yes yes yes
Persistence across restart apcu: no yes (it's your DB) optional (RDB/AOF)
Cost per hit disk/syscall query + row lock network round trip
cache:clear blast radius one server one table one database — check prefixes

Two operational details save real outages. First, php artisan cache:clear on Redis deletes keys matching the current CACHE_PREFIX — if you change the prefix (say, per-release), old keys become unreachable garbage and you must purge them manually (redis-cli --scan --pattern 'old-prefix:*' | xargs -r redis-cli del). Second, the array store is per-request: it is perfect in tests (fast, isolated) and useless in production, where it forgets everything between requests.

Keep the cache out of your incident path entirely: alert on hit rate and on used_memory approaching maxmemory, not on individual key misses. When the read layer is healthy, the interesting failures move to the write side — jobs and scheduled work — starting with Scheduling Recurring Tasks. If you have not yet read Dispatching Queued Jobs, notice that the queue tables and this cache share one Redis instance in most deployments: starving one starves the other, which is why they were separated into distinct databases above.

Interview framing: "How do you choose a cache backend?" Answer with the constraints — shared state across servers, atomicity for locks and counters, memory ceiling and eviction policy, and restart durability — then say which constraint eliminated the file driver for you. That is a design answer, not a shopping answer.

Quiz

1. In cache-aside with Cache::remember, which statement describes what happens on a hit?

Question 1 options

2. Your app runs on three web servers with CACHE_STORE=file. After updating a product you call Cache::forget(). Why can another server still serve the old price?

Question 2 options

3. Redis is configured with maxmemory 2gb and maxmemory-policy noeviction, and it just hit the limit. What happens?

Question 3 options

4. You change CACHE_PREFIX for a new release. Why does old cached data effectively disappear, and what cleanup does that require?

Question 4 options

Flashcards

Question

Cache-aside in one sentence, and its inherent weakness

Answer

The application checks the cache first and, on a miss, computes the value itself and writes it back — the store only holds data. Weakness: every miss pays full compute cost synchronously in the request, so a cold key or expired hot key produces a thundering herd.

Question

Cache-aside vs write-through

Answer

Cache-aside: reads fill the cache lazily, writes hit only the database, simplest and stale until TTL/delete. Write-through: each write updates cache and database together so reads are always fresh, at the cost of double work per write and coherence logic.

Question

Why Redis carries cache, sessions, and queues together — and how to keep that safe

Answer

Sub-millisecond network reads, atomic single-threaded operations (INCR, SET NX locks), and RDB/AOF persistence that survives restarts. Keep them safe with distinct databases or key prefixes (CACHE_PREFIX, QUEUE_PREFIX, SESSION_PREFIX) so cache:clear can't wipe queues and cache eviction can't touch sessions.

Question

What APCu and the file driver each give you — and the boundary both share

Answer

File: no extra server, disk latency per hit, no atomic ops. APCu: ~1 µs in-process shared memory, gone on restart. Both are per-machine: nothing a second server does is visible, which rules them out for shared sessions, cross-server invalidation, and Cache::lock coordination.

Question

The two metrics that justify a cache, and how to read them from Redis

Answer

Hit rate and miss cost: redis-cli info stats gives keyspace_hits/keyspace_misses (hits ÷ total); multiply saved latency by hits to see the benefit. Below a high hit rate with a cheap miss path, the cache is overhead — fix the query instead.

Revision Notes

Key Takeaways

  • 1. Cache-aside is lazy population by the application: remember() runs the closure only on a miss, and the store never queries anything itself
  • 2. A cache key must contain every input that changes the output — locale, currency, tier — or one variant is served to everyone
  • 3. The file and APCu stores are per-machine, so on a multi-server fleet invalidation and locks silently don't propagate
  • 4. Redis earns its central role with atomic operations and persistence, but needs separate databases or prefixes plus a deliberate eviction policy
  • 5. Measure before trusting: hit rate from keyspace_hits/misses and memory headroom against maxmemory decide whether the cache is earning its cost

Interview Tips

  • • "Explain cache-aside" — app-first read, closure on miss, write-back; then immediately name its weakness (cold-key stampede) and the TTL as a staleness bound
  • • "How do you choose a cache backend?" — walk the constraints: shared state across servers, atomicity for locks/counters, memory ceiling and eviction, restart durability; conclude with the constraint that eliminated the file driver
  • • "What happens when Redis runs out of memory?" — noeviction rejects writes (outage) vs allkeys-lru evicts (data loss); say which policy fits a dedicated cache DB and why sharing one DB with queues is a trap
  • • "How do you know your cache is working?" — hit rate and miss cost, plus the invalidation-correctness checks: key composition, TTL as staleness ceiling, and single-store sharing across servers

Cheat Sheet

Caching with Redis and Files

$product = Cache::remember(
    "product:{$id}:{$locale}:{$tier}",   // every output-changing input in the key
    now()->addMinutes(10),                // TTL = max staleness you can defend
    fn () => $this->buildProductView($id) // runs on miss only
);
Cache::increment('rl:ip');                       // atomic on Redis
$lock = Cache::lock('report', 30)->block(5, fn () => ...);  // lock-capable store required

Store matrix:

Store Shared? Locks/atomic Notes
file no (per server) no dev / single box
database yes limited no new infra, row locks
redis yes yes production default, sub-ms
memcached yes limited size-based eviction
apcu no (per machine) no ~1 µs, RAM cache for config/routes
array no (per request) no tests only
redis-cli ping
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'   # hit rate
redis-cli info memory | grep -E 'used_memory_human|maxmemory'
redis-cli --scan --pattern 'myapp:cache:*' | head   # never KEYS *
php artisan cache:clear      # respects current CACHE_PREFIX only

Eviction: allkeys-lru for a dedicated cache DB; never let it share a DB with sessions or queues. Prefix change ⇒ old keys orphaned ⇒ scan + delete manually.

Continue Learning

Before You Start

Related Reading