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?
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?
3. Redis is configured with maxmemory 2gb and maxmemory-policy noeviction, and it just hit the limit. What happens?
4. You change CACHE_PREFIX for a new release. Why does old cached data effectively disappear, and what cleanup does that require?
Flashcards
Question
Cache-aside in one sentence, and its inherent weakness
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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.