Caching makes your app faster and your data wrong. Here is what to do about it.

The first time you wrap a slow query in Cache::remember() and watch your response time drop from 800ms to 12ms, you feel like a genius. That is the danger.
Caching works brilliantly until the moment it doesn’t, and when it fails, it fails quietly. Your database has the correct data. Your users are looking at the wrong data. Your monitoring shows green. No exception was thrown. No error was logged. The bug is invisible until a customer notices their invoice shows the old price or their account balance is wrong.
Caching is not free performance. It is a trade-off between speed and correctness. Most teams accept the trade-off without fully understanding what they gave up.
The Three Ways Your Cache Lies to You
There are three distinct failure modes in application-level caching. Most developers have experienced the first one. Fewer have thought hard about the second, and almost nobody reasons carefully about the third.
Stale data after a write.
This is the classic one. You update a record in the database and forget to invalidate the cache. The next request reads the cached value, which no longer reflects reality. Sometimes this is obvious. More often, it takes days to surface because it only affects certain records or code paths.
The invalidation race condition.
This is sneakier. You do invalidate the cache after a write, but invalidation and cache population are not atomic. Here is the sequence that breaks things: a request updates a record at time T, then calls Cache::forget($key) at time T+1ms. In that 1ms window, another concurrent request ran, found the key absent in cache, hit the database, read the now-stale data before the write transaction committed and wrote that stale value back to cache. Now your cache has the wrong value and a TTL of 30 minutes. The explicit forget was pointless.
Missing invalidation paths.
This one is purely a modeling problem. You cache a query result that depends on data from three tables. You remember to invalidate when table A changes. You forget that table B and table C can also change the result. A background job updates a row in table B. The cache is never touched. The bug sits there indefinitely.
The third failure mode is what Phil Karlton was pointing at with the famous observation that cache invalidation is one of the two hard problems in computer science. It is not hard because the mechanism is complicated. It is hard because you have to enumerate all possible mutation paths that affect a given piece of cached data, at the time you write the code, before you know all the ways that data will change in the future. That is a knowledge problem, not an engineering problem.
Cache Tags Will Not Save You
Laravel’s cache tag system looks like a solution to the “missing invalidation paths” problem. Group related cache entries under a tag, and when the underlying data changes, flush the entire tag. Elegant in theory.
In practice, cache tags in Laravel have a history that should make you cautious.
First, they only work with specific cache drivers. As of Laravel, tags require a Redis or Memcached driver. If you switch to a database or file cache in any environment (testing, local development, a staging server with a different config), your tag-based invalidation silently stops working or throws exceptions.
Second, in Laravel, cache tags were quietly removed from the official documentation. The relevant commit note from the maintainers cited “complexity of implementation” as the reason. This is not a deprecation notice. It is just an acknowledgment that the feature is harder to use correctly than it looks. The feature still works. It is just no longer something the Laravel team recommends building around.
Third, and most practically, tags do not solve the enumeration problem. You still have to know, upfront, that a given piece of cached data needs to be tagged and what tag name it belongs to. If you miss a write path, the tag flush never runs. You have the same bug with extra steps.
Tag-based invalidation is useful for bulk flushes where approximate correctness is fine, like invalidating all cached product listing pages when you update inventory. It is not a reliable mechanism for ensuring that individual entities always reflect their current database state.
The Thundering Herd Nobody Warned You About
Assume you have fixed the invalidation problem. You are invalidating correctly and promptly. You still have a potential failure mode called a cache stampede, or thundering herd.
A popular cache key expires. In the same millisecond, 200 concurrent requests notice the miss and all try to recompute the value. All 200 requests hit the database. If the computation is expensive (a join across several large tables, for example), the database just absorbed 200x the expected load in an instant. Depending on your DB server’s capacity, this either degrades performance significantly or takes the database down entirely.
This failure mode is most dangerous when your TTLs are short and your traffic is high. It is also insidious because the problem peaks right after a cache expiry, not during normal load, so your database load graphs look jagged and confusing rather than clearly tracking traffic.
The standard mitigation is a distributed lock. When a request encounters a cache miss, it tries to acquire an exclusive lock before computing the value. If it gets the lock, it does the computation and populates the cache, then releases the lock. If another request tries to get the lock and fails because the first request already has it, that second request waits briefly and then reads from the cache, which should now be populated.
In Laravel with Redis, this looks like:
$value = Cache::remember($key, $ttl, function () {
return DB::table('orders')
->where('user_id', $this->userId)
->sum('total');
});The standard Cache::remember() call does not protect against stampedes. To protect against them, you need to use Cache::lock() explicitly:
$value = Cache::get($cacheKey);
if ($value === null) {
$lock = Cache::lock('lock:' . $cacheKey, 10);
if ($lock->get()) {
try {
// Re-check cache after acquiring the lock
$value = Cache::get($cacheKey);
if ($value === null) {
$value = DB::table('orders')
->where('user_id', $this->userId)
->sum('total');
Cache::put($cacheKey, $value, $ttl);
}
} finally {
$lock->release();
}
} else {
// Another process is populating the cache. Wait briefly and read.
usleep(100000); // 100ms
$value = Cache::get($cacheKey);
}
}
return $value;This is more verbose than Cache::remember(). That verbosity is honest. It shows you the actual complexity you are managing.
The Cache::lock() implementation in Laravel uses Redis's SET NX command under the hood, which is an atomic operation. Only one process can set a key that does not already exist. This gives you a reliable distributed mutex without rolling your own atomic logic.
Note the double-check after acquiring the lock. By the time your process acquires the lock, another process might have already populated the cache while you were waiting. Skipping the re-check means you do unnecessary database work and overwrite a perfectly good cache entry.
The Real Question Before You Cache Anything
Most caching tutorials start with “here is how to cache.” The question you should start with is: “Can I reliably identify every write path that changes this data, for the entire future lifetime of this application?”
If you cannot answer that question with confidence, you should think hard before caching. Not because caching is bad, but because you are making a correctness bet you might not be able to honor.
Some data is safe to cache because it has a small, well-understood set of write paths. A user’s profile, updated only through the account settings form. A product’s price, updated only through an admin panel. A configuration value, changed only via a deploy.
Other data is dangerous to cache because it is a derived value that changes whenever any of several underlying tables change. An order total that depends on line items, discounts, shipping rules and tax rates. A user’s permission set that changes when their roles change, when role permissions change, or when specific overrides are set. An aggregated dashboard metric that recomputes based on dozens of events.
The rule is not “do not cache derived values.” The rule is: know what you are caching and why, and be explicit about every invalidation point.
A practical way to enforce this is to document your invalidation strategy in the code next to the cache call:
// Cache: user_summary_{userId}
// Invalidated by:
// - UserProfileUpdated event (UserObserver::updated)
// - UserRoleAssigned event (UserObserver::roleSaved)
// - OrderCompleted event (OrderObserver::created)
// TTL: 15 minutes (fallback for any missed invalidation)
$summary = Cache::remember("user_summary_{$userId}", now()->addMinutes(15), function () use ($userId) {
return $this->userSummaryService->build($userId);
});This comment costs nothing to write and forces you to think through every mutation path at the time you add the cache. When a new write path is added to the system later, the next developer can see the invalidation list and update it. Without this, the list lives in someone’s head or nowhere at all.
When Not to Cache
Some things look like good caching candidates but are not:
User-specific aggregates with frequent mutations. If a user’s data changes on every request (view counts, activity timestamps, running totals), caching it means either frequent invalidation (which eliminates the benefit) or stale data that accumulates quickly. Consider whether you actually need real-time accuracy here, and if not, be explicit about the staleness window you are accepting.
Data that comes from a single fast query. If your database query takes 5ms, caching it to make it take 0.5ms is not meaningfully improving user experience. You are adding correctness risk for a speedup users cannot perceive. Profile before you cache. Cache where it actually matters.
Anything that requires exact real-time accuracy for correctness. Available inventory in an e-commerce store. Account balances. Seat counts in a booking system. Caching these and getting them slightly wrong is not a performance trade-off. It is a data integrity problem with real consequences. Here, you need database reads or at minimum an explicit staleness window that your product team has signed off on.
Caching Is Opt-In Complexity
Every cache entry you add is a new piece of distributed state your application has to manage. It is a second source of truth that can diverge from your database. It is an extra codepath that needs to be invalidated correctly when data changes, monitored for hit rates and reasoned about during debugging.
None of this means you should not cache. It means you should cache deliberately, document your invalidation strategy explicitly, protect against stampedes on high-traffic keys and build your TTL as a safety net rather than your primary invalidation mechanism.
The developers who get burned by caching bugs are rarely the ones who added too little caching. They are the ones who added caching at the query level, forgot to enumerate all the write paths and then wondered why a user’s dashboard shows data from three weeks ago.
Cache what you understand. Know how you will invalidate it. Be honest about the staleness window you are accepting.
That is the whole job.
If you have Cache::remember() calls in your codebase right now, pick one and trace every write path that could change the underlying data. You will either feel very confident about your invalidation setup or you will find a bug. Either outcome is useful.


