
September 12, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Stale data is the silent bug in most web apps. You ship a price change, a booking slot update, or a legal document revision—and users still see yesterday's version because nobody planned cache invalidation patterns at the architecture stage. On production Laravel and WordPress systems I maintain, bad invalidation shows up as support tickets, SEO canonical mismatches, and payment totals that do not match the database. This guide walks through the patterns that actually work across application cache, Redis caching layers, and edge CDNs—not the textbook version that ignores race conditions.
What are cache invalidation patterns and why do they matter?
Every cache sits between a fast read path and a slower source of truth. That source might be MySQL 9.7, PostgreSQL 18, an external API, or a rendered Blade view. The hard part is not storing values—it is knowing when those values are wrong.
Phil Karlton's old joke still holds: there are only two hard things in computer science, and one of them is cache invalidation. In practice, the problem is not deleting a key. The problem is deleting the right keys at the right time without crushing your database under a stampede.
I've seen this on legal-tech portals where a fee table update left old amounts cached for hours. I've also seen it on booking systems with Livewire dashboards where availability counts drifted after a concurrent reservation. Both cases needed explicit invalidation design—not a longer TTL and hope.
A useful mental model: classify what you cache by volatility and blast radius. Volatility is how often the underlying data changes. Blast radius is how many pages or users a stale entry can affect. High blast radius plus high volatility demands event-driven invalidation. Low volatility reference data can survive on TTL alone.
Core pattern names you should recognise
- Cache-aside (lazy loading): App reads cache first; on miss, loads DB and writes cache.
- Write-through: App writes DB and cache in the same request.
- Write-behind: App writes cache immediately; DB write is deferred.
- Read-through: Cache layer itself loads missing keys from DB.
- TTL-only: Entries expire on a timer with no explicit delete.
Most Laravel apps use cache-aside with Redis. That is fine. The invalidation pattern you choose must match how writes happen in your domain. A WooCommerce 11.1 store and a custom Laravel cart both cache product lists—but invalidation triggers differ when plugins hook into save events.
How do you invalidate cache in Laravel applications?
Laravel 12 and 13 expose a consistent cache API regardless of driver. Redis 8.10 is the default choice on servers I administer. Memcached 1.6.x still appears on legacy stacks. The invalidation API is the same; tag support is not.
Start with predictable cache keys. I prefer namespaced keys that include entity type, ID, and a version suffix when schema changes:
/* app/Services/ProductCache.php */
public function key(int $productId): string
{
return sprintf('catalog:product:%d:v2', $productId);
}
public function remember(int $productId): Product
{
return Cache::remember(
$this->key($productId),
now()->addHour(),
fn () => Product::with('category')->findOrFail($productId)
);
} When the product updates, delete that key—or flush a tag group if you use Redis with tag support. Laravel documents tag usage in the official cache tags guide. Tags work with Redis and Memcached in Laravel; they do not work with the file or database drivers.
Event-driven invalidation in Laravel
The cleanest pattern I've used on production apps: invalidate in the model layer or via domain events—not scattered across controllers.
/* app/Observers/ProductObserver.php */
public function saved(Product $product): void
{
Cache::forget(sprintf('catalog:product:%d:v2', $product->id));
Cache::tags(['catalog', 'category:'.$product->category_id])->flush();
} Register the observer in a service provider. One write path means one invalidation path. Controllers stay thin. This mirrors patterns from Symfony's event dispatcher approach—react to state change, do not poll cache.
For heavier workloads, queue the flush. A tag flush on a large Redis key set can block the request thread. Dispatch a job:
CacheInvalidationJob::dispatch(
tags: ['catalog', 'category:'.$product->category_id],
keys: [sprintf('catalog:product:%d:v2', $product->id)]
); That keeps checkout and admin saves fast while Redis catches up within seconds. For most business sites, that delay is acceptable. For inventory with strict counts, use write-through or skip caching hot counters entirely.
Cache tags: power and limits
Tags let you invalidate groups without maintaining a key registry. I compared drivers in a separate write-up on Laravel cache tags with Redis vs Memcached. Redis stores tag relationships in separate keys. Flushing a tag walks those references.
Do not tag everything. Each tag adds metadata overhead. A common layout:
- One tag per aggregate root:
product:123,lawyer:45. - One tag per list context:
products:category:8,posts:page:1. - Global layout tag:
site:settingsfor config that affects all pages.
On a legal directory I shipped, lawyer profile pages used lawyer:{id} plus city:{slug} list tags. A profile edit flushed both. A city-wide SEO change flushed only the city tag. That cut over-invalidation compared to a single global flush.
When should you use TTL vs event-driven invalidation?
TTL is the simplest cache invalidation pattern. Set Cache::remember($key, 300, $callback) and accept up to five minutes of staleness. That works for semi-static reference data: forex rates, court fee tables, fuel prices. It fails for authoritative business data users act on immediately.
Use this decision matrix before you code:
| Scenario | Recommended pattern | Typical TTL | Risk if wrong |
|---|---|---|---|
| Product price or stock | Event-driven key delete | None or short safety TTL | Wrong charge, oversell |
| Blog post content | Event on publish + CDN purge | 1–24 hours fallback | SEO stale snippet |
| Exchange rates / fuel prices | TTL only | 15–60 minutes | Minor display drift |
| Session cart | Do not cache across users | Session lifetime | Data leak |
| Rendered HTML fragment | Tag flush on entity change | 1–6 hours fallback | Visible stale UI block |
| API rate-limit counters | Fixed window TTL | 60 seconds | Abuse or false blocks |
Hybrid approaches win most debates. I combine event invalidation with a conservative TTL as a safety net. If an observer fails silently, stale data expires anyway. The TTL is not your primary strategy—it is insurance.
For public tools that refresh on a schedule—like a Nepal fuel price tracker—TTL aligns with upstream publish frequency. A 30-minute TTL matches reality when the source updates twice daily. Event-driven invalidation would add complexity with no user benefit.
What are common cache invalidation mistakes in production?
The failures I debug repeatedly fall into a short list. Teams know caching helps performance. They skip the invalidation map until something breaks in production.
1. Cache stampede after mass invalidation
You flush a popular tag. Fifty concurrent requests miss at once. All fifty hit MySQL for the same heavy query. Response times spike. PHP-FPM workers saturate.
Mitigations that work:
- Probabilistic early expiration: Recompute slightly before TTL under load.
- Mutex / lock: Only one worker rebuilds; others wait or serve stale.
- Stale-while-revalidate: Serve old value while async refresh runs.
Laravel's Cache::lock() helps:
$product = Cache::remember($key, 3600, function () use ($key, $id) {
$lock = Cache::lock('lock:'.$key, 10);
try {
$lock->block(5);
return Product::with('media')->findOrFail($id);
} finally {
optional($lock)->release();
}
}); Redis documents similar lock patterns in their distributed locks guide. Use short lock TTLs. A crashed worker should not block rebuilds forever.
2. Forgetting CDN and opcode layers
Invalidating Redis does nothing for a Cloudflare-cached HTML page. Your app returns fresh JSON while the edge still serves Thursday's HTML. I covered API bypass headers in Cloudflare DNS cache bypass for API endpoints. The same principle applies to purge calls.
After content publishes, call your CDN purge API with URL lists or cache tags. Pair that with Cache-Control headers that match your invalidation strategy. Static assets with hashed filenames can live forever. HTML should not.
3. Caching without a version dimension
Deploy a Blade template change while fragment cache keys stay identical. Users see old HTML wrapped around new CSS—or worse, broken layout. Bump a global cache version in config on deploy:
/* config/cache.php or .env */
'store_version' => env('CACHE_VERSION', '2026091201'),
/* usage */
$key = config('cache.store_version').':home:featured'; Sites I deploy with Deployer 7 bump this version in the release hook. Old keys become orphans and expire naturally. No manual Redis flush at 2 a.m.
4. Invalidating too much or too little
Cache::flush() on Redis flushes the entire database—not just your app if you share an instance. That is a production incident waiting to happen. Prefer tags or explicit keys.
Under-invalidation is quieter but worse. You cache a lawyer directory filtered by city and practice area but only flush on lawyer edit—not when a city slug changes. The list tag must include every dimension that affects output. Trace one cached response back to its inputs. Those inputs are your tag set.
How do you handle cache invalidation across Redis, Symfony, and WordPress?
Not every project runs Laravel. The patterns transfer. The APIs differ. On Symfony 8.1 projects I use the Cache component with explicit invalidation strategies documented in the Symfony Cache component docs. Tag-aware adapters mirror Laravel's tag flush.
WordPress 7.1 with Redis object cache invalidates on save_post, edited_term, and plugin-specific hooks. WooCommerce clears product transients on product save—but custom queries you add still need manual clears. I've written about setup in WordPress object cache with Redis.
Multi-layer invalidation checklist
When data changes in a write request, walk this sequence:
- Commit the database transaction.
- Delete or tag-flush application cache keys in Redis.
- Dispatch CDN purge for affected URLs or surrogate keys.
- Bump view or config version if templates changed.
- Log invalidation for debugging—key names, tags, actor, timestamp.
Order matters. Purge CDN before DB commit and you may cache error pages or old content twice. Commit first, then invalidate outward.
For read-heavy APIs, consider whether CQRS separation simplifies invalidation. Writes go to a normalized store. Reads pull from a materialized cache or read model you rebuild on events. That adds architecture cost. It pays off when read patterns are complex and writes are bursty.
Monitoring and testing invalidation
You cannot fix what you cannot see. Enable Redis MONITOR sparingly in staging—not production. Better: log cache misses and invalidation events at info level. Track hit ratio in your APM or a simple daily Artisan command.
Write feature tests that assert invalidation:
public function test_updating_product_clears_cache(): void
{
$product = Product::factory()->create();
$key = "catalog:product:{$product->id}:v2";
Cache::remember($key, 3600, fn () => $product->fresh());
$this->assertTrue(Cache::has($key));
$product->update(['price' => 999]);
$this->assertFalse(Cache::has($key));
} That test catches observers someone removed during a refactor. Pair it with integration tests on testing and optimization workflows before major releases.
How do you design cache invalidation for high-traffic eCommerce?
eCommerce adds inventory concurrency to every caching decision. On WooCommerce florist stores and Laravel carts I've built, product list pages are cached aggressively. Stock counts often are not—or they use very short TTL with event invalidation on order placement.
Practical rules for carts and checkout:
- Never cache personalised prices or cart totals under shared keys.
- Cache category trees and static filters with tag invalidation on catalog edits.
- Invalidate list tags when any product in that list changes—not only detail keys.
- Separate cache namespaces for admin previews vs public storefront.
Payment gateway callbacks need idempotent cache clears. A duplicate webhook should not double-flush or skip flush due to race logic. Store processed webhook IDs in Redis with a TTL. Clear product cache once per unique event.
Performance work belongs in the same conversation as invalidation. See speed optimization practices and Eloquent query patterns for large datasets when rebuild queries are the real bottleneck—not Redis itself.
Key Takeaways
- Map every cached response to its source entities before choosing TTL, tags, or events—blast radius drives the pattern.
- Invalidate on write via observers or domain events; use TTL only as a safety net, not the primary strategy for money or inventory.
- Protect hot keys with
Cache::lock()after mass invalidation to prevent database stampedes. - Purge CDN and bump cache version on deploy; Redis-only invalidation leaves stale HTML at the edge.
- Never call
Cache::flush()on shared Redis; use tags or explicit keys with namespaced prefixes. - Test invalidation in feature specs so refactors do not silently reintroduce stale data.
People Also Ask
What is the difference between cache eviction and cache invalidation?
Eviction removes entries because the cache is full—LRU or LFU policies decide what goes. Invalidation removes entries because the data is logically stale. Eviction is about memory pressure. Invalidation is about correctness. Production apps need both, but only invalidation prevents users from acting on outdated prices or availability.
Does Laravel Cache::forget delete Redis keys immediately?
Yes, for the Redis driver Cache::forget($key) issues a DEL command against that key synchronously in the same request. Tag flushes may delete multiple keys and take longer. Queuing large flushes avoids blocking user-facing responses on heavy tag sets.
How long should cache TTL be?
TTL should match acceptable staleness for that data type. Reference rates can use 15–60 minutes. Product detail pages with event invalidation might use a 1-hour fallback TTL. Checkout, auth, and per-user data should not use shared TTL caches at all. There is no universal number—derive it from business tolerance.
Can you invalidate Cloudflare cache from Laravel?
Yes. Call the Cloudflare API purge endpoint from a job triggered after content saves. Pass specific URLs or cache tags configured as surrogate keys on responses. Pair API purges with correct Cache-Control headers so future responses cache with the right lifetime. API routes often need bypass rules as described in edge cache guides.
Build caching that stays correct after launch
Cache invalidation patterns are not an optimisation detail you add after launch. They are part of your data contract—the same way migrations and validation rules are. Define what you cache, what triggers a purge, and which layers participate. Then test it before the first stale-price support ticket arrives.
If your Laravel or WordPress app serves cached data that drifts from the database—or you are adding Redis to a growing platform—I can audit invalidation paths, stampede risk, and CDN config as part of ongoing support and maintenance or a focused performance review. For greenfield work, see web development services or enterprise application development.
Related reading: Symfony cache with Redis, high availability architecture, circuit breakers and resilience, and API gateway patterns. Need a second pair of eyes on production cache behaviour? Contact us with your stack details—Redis version, CDN, and where stale data appears today.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

