Redis in a Laravel app is usually introduced as a performance win. Put this query in the cache. Remember this config. Session driver, why not.
Then one day the cache contains a decision that is no longer true, or five hundred workers stampede the database because a key expired, and Redis is suddenly a correctness problem wearing a speed costume.
This is how I think about it now, after enough of those days.
Cache aside is the default, and that is fine
Laravel’s Cache::remember is cache-aside:
- Look up the key
- If missing, run the callback
- Store the result
It is easy to reason about. The database remains the source of truth. Redis is a hint.
The hint can be stale. That is the contract. If you cannot tolerate stale, you do not want a TTL cache. You want the value to be invalidated when the write happens, or you want to not cache it.
Invalidation is the actual design
I treat TTL as a safety net, not as a strategy.
If a user updates a setting and the next request still shows the old setting for 30 minutes, the cache is not “working as designed”. It is lying.
The rule I try to keep:
- Writes that users can see must invalidate the keys those pages read.
- TTL exists for keys you forgot, for process crashes, for the day invalidation misses.
Naming keys carefully is part of invalidation. user:42:nav is invalidatable. page:dashboard is a junk drawer.
Stampede
Cache::remember does not, by itself, prevent a thundering herd. When a hot key expires, every worker can miss at once and every worker can recompute.
For most keys this does not matter. For a report query that takes two seconds and is on the homepage, it does.
Options I have used, in increasing annoyance:
- a slightly longer TTL than you think you need
- locking around rebuild (
Cache::lock) - precomputing into a known key from a scheduled job so the request path almost never rebuilds
I do not start with the clever option. I start with “is this key actually hot?”
What I refuse to cache
- anything that is a permission check unless I am very sure about invalidation
- unpaid / paid state that gates a feature
- “temporary” values that became load-bearing
Redis will store a bad idea with excellent latency.
Redis is also not only a cache
Queues, locks, rate limiters, sessions. Those are different contracts. A memory eviction policy that is acceptable for a cache is not acceptable for a lock. Mixing them on one instance without thinking about maxmemory-policy is how you get a mysteriously lost lock at 3am.
I like Redis a lot. I like it more when each key has a sentence attached: this may be stale, or this must not be evicted, or this is a queue, not a cache.