The bug that looks like it isn't there
A user updates their profile. They see the change save successfully. They refresh the page — and their old data is still showing.
No error. No failed request. The database has the new value. But the app keeps showing the old one.
This is one of the most confusing bugs to debug in Laravel, because everything looks correct. The form submitted. The controller ran. The database updated. The bug isn't in your business logic — it's in your cache.
Why this happens
Caching speeds up your app by storing the result of an expensive operation (a query, an API call, a calculation) so you don't have to redo it every time. That's the whole point.
But caching creates a new problem: the cached copy doesn't know when the real data changes.
// Somewhere in your app
$user = Cache::remember("user.{$id}", 3600, function () use ($id) {
return User::with('posts', 'profile')->find($id);
});
This works fine — until the user updates their profile. The database changes. The cache doesn't. For the next hour (or however long your TTL is), every request for this user gets the stale, cached version.
This is called a stale read, and it's the single most common caching bug in production apps.
The naive fix (and why it doesn't scale)
The obvious fix is: clear the cache key when the data changes.
public function update(Request $request, User $user)
{
$user->update($request->validated());
Cache::forget("user.{$user->id}");
}
This works for one cache key tied to one model. But real apps rarely cache just one thing per user. You might cache:
- The user's profile
- Their recent posts
- Their dashboard stats
- A "top users" leaderboard that includes them
Now every place that touches a User needs to remember every cache key that might contain that user's data, and manually clear all of them. Miss one, and you've got a stale read that's much harder to track down than the first one — because now it only happens sometimes, depending on which code path updated the user.
This is where cache tags come in.
What cache tags actually do
Instead of tracking individual cache keys, tags let you group related cache entries under a label, and clear the whole group in one call.
$user = Cache::tags(['users', "user.{$id}"])->remember("user.{$id}.profile", 3600, function () use ($id) {
return User::with('posts', 'profile')->find($id);
});
Now, when the user updates, you don't need to know every key that touched their data. You just flush the tag:
Cache::tags("user.{$id}")->flush();
Every cache entry tagged with user.{$id} — profile, dashboard stats, whatever else you tagged the same way — gets cleared at once. You clear by what changed, not by which keys you remember to clear.
The gotcha that catches almost everyone
Here's the part that isn't obvious until it bites you in production:
Cache tags only work with the Redis and Memcached drivers.
If your .env has:
CACHE_STORE=file
or
CACHE_STORE=database
...then calling Cache::tags(...) will throw a BadMethodCallException, because the file and database cache drivers have no concept of tags — they just store flat key-value pairs.
This usually surfaces in one of two painful ways:
- It works locally, breaks in production (or the reverse) — because your local
.envusesfilefor simplicity, but production usesredis. The code passes on your machine and blows up on deploy. - It fails silently in tests — if your test environment falls back to the
arraydriver (which does support tags, confusingly), your test suite gives you false confidence that tagging works everywhere.
The fix: confirm your cache driver before you design around tags. Check it directly:
php artisan tinker
>>> config('cache.default')
If you're on file or database and can't switch to Redis, you're back to manually tracking keys — or you build a lightweight tagging layer yourself using key prefixes and a version counter (a pattern worth its own article, but outside scope here).
A safer pattern: invalidate on the model, not in the controller
Scattering Cache::tags(...)->flush() calls across every controller method that might touch a User is fragile — it's easy to add a new update path (a queued job, an Artisan command, a bulk import) and forget to clear the cache there too.
A more reliable pattern is to hook invalidation into the model itself, using an observer:
class UserObserver
{
public function saved(User $user): void
{
Cache::tags("user.{$user->id}")->flush();
}
public function deleted(User $user): void
{
Cache::tags("user.{$user->id}")->flush();
}
}
Register it in your AppServiceProvider:
public function boot(): void
{
User::observe(UserObserver::class);
}
Now, no matter where or how a User gets updated — a form, a job, a console command, a factory in a seeder — the cache clears automatically. You've moved invalidation from "something a developer has to remember" to "something the framework guarantees."
Choosing a sensible TTL
Tags solve correctness — making sure stale data gets cleared when it changes. But you still need to pick a time-to-live (TTL) for data that doesn't have an obvious invalidation trigger, like a "trending posts" list built from activity across the whole app.
A few practical guidelines:
- Data with a clear owner and clear update events (a user profile, a single post) → tag it and invalidate on save. TTL can be long (hours) since invalidation handles freshness.
- Aggregated or computed data (leaderboards, trending lists, analytics summaries) → short TTL (minutes), because there's no single "save" event to hook into. Let it expire naturally instead of chasing every event that could affect it.
- Never cache without a TTL "just in case." Always set one, even a long one. An unbounded cache entry with a missed invalidation path becomes a bug that only shows up weeks later, and by then nobody remembers what set it.
The takeaway
Stale reads aren't a sign your caching logic is wrong — they're a sign your invalidation logic is incomplete. Caching itself is simple; keeping cached data in sync with the source of truth is the actual engineering problem.
Three habits prevent most of these bugs:
- Confirm your cache driver supports tags before you design around them (Redis or Memcached only).
- Tie invalidation to the model via an observer, not scattered
Cache::forget()calls in controllers. - Give everything a TTL, even tagged data — treat tags as your primary defense and TTL as your safety net.