Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Cache Invalidation Patterns

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.

Cache Invalidation LayersBrowserHTTP cacheCDN / EdgeSurrogate keysApp CacheRedis 8.10DatabaseSource of truthInvalidation TriggersTTL expiryKey deleteTag flushEventsEach layer needs its own invalidation rule
Cache invalidation patterns span browser, CDN, application cache, and database—each layer needs explicit rules.

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:

  1. One tag per aggregate root: product:123, lawyer:45.
  2. One tag per list context: products:category:8, posts:page:1.
  3. Global layout tag: site:settings for 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:

ScenarioRecommended patternTypical TTLRisk if wrong
Product price or stockEvent-driven key deleteNone or short safety TTLWrong charge, oversell
Blog post contentEvent on publish + CDN purge1–24 hours fallbackSEO stale snippet
Exchange rates / fuel pricesTTL only15–60 minutesMinor display drift
Session cartDo not cache across usersSession lifetimeData leak
Rendered HTML fragmentTag flush on entity change1–6 hours fallbackVisible stale UI block
API rate-limit countersFixed window TTL60 secondsAbuse 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.

TTL vs Event InvalidationData changed?NoTTL onlyReference data OKYesUser action?Money or bookingEvent deleteImmediate purgeTTL + eventHybrid safety netNever rely on TTL alone for financial or inventory data
Decision tree for choosing TTL-only, event-driven, or hybrid cache invalidation patterns.

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.

Cache Stampede After Tag FlushTag flush50 requestsCache missNo lock50 DB queriesWith lock1 DB query49 waitServe freshPrevention ChecklistCache::lock on rebuildQueue large tag flushes off the request path
Mass invalidation without locking triggers a cache stampede—use mutex locks or stale-while-revalidate to protect the database.

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:

  1. Commit the database transaction.
  2. Delete or tag-flush application cache keys in Redis.
  3. Dispatch CDN purge for affected URLs or surrogate keys.
  4. Bump view or config version if templates changed.
  5. 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.

Correct Invalidation OrderWrite requestPOST / saveDB commitSource of truthRedis flushKeys and tagsCDN purgeEdge HTMLWrong Order = Double Stale or Error CachePurge before commitCommit then purgeLog every purge with entity ID and tag names
Multi-layer cache invalidation must follow database commit, then Redis, then CDN purge—in that order.

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

Structured methods to remove or refresh cached data when the source of truth changes, including TTL expiry, key deletion, tag purge, event-driven invalidation, and write-through updates across application cache, Redis, and CDN layers.

Every cache sits between a fast read path and a slower source of truth such as MySQL 9.7, PostgreSQL 18, or a rendered Blade view. Stale entries cause support tickets, wrong payment totals, SEO canonical mismatches, and booking availability drift. The hard part is not deleting a key but deleting the right keys at the right time without crushing the database under a stampede after mass invalidation.

Cache-aside means the app reads cache first and loads the database on miss, then writes cache. Write-through means the app writes the database and cache in the same request. Most Laravel apps use cache-aside with Redis 8.10, which works fine, but the invalidation pattern must match how writes happen in your domain. Inventory with strict counts often needs write-through or no caching on hot counters at all.

Use predictable namespaced keys such as catalog:product:{id}:v2, then call Cache::forget on update or Cache::tags()->flush for grouped data. Register model observers or domain events so one write path triggers one invalidation path instead of scattering deletes across controllers. For heavy tag flushes that block request threads, dispatch a queued CacheInvalidationJob with the affected tags and keys so checkout and admin saves stay fast while Redis catches up within seconds.

TTL works for semi-static reference data such as forex rates, court fee tables, or fuel prices where up to several minutes of staleness is acceptable. It fails for authoritative business data users act on immediately, like product price, stock, or session carts. A hybrid approach wins most debates: event invalidation as primary strategy with a conservative TTL as insurance if an observer fails silently. For a Nepal fuel price tracker updating twice daily, a 30-minute TTL matches upstream frequency without event complexity.

Tags let you invalidate groups of related keys without maintaining a key registry. They work with Redis and Memcached 1.6.x drivers but not file or database drivers. Each tag adds metadata overhead, so do not tag everything. A practical layout uses one tag per aggregate root like product:123, one per list context like products:category:8, and a global site:settings tag for config affecting all pages. Flushing a tag walks Redis reference keys, which can be expensive on large sets.

A cache stampede happens when mass invalidation causes many concurrent requests to miss cache simultaneously and all hit MySQL for the same heavy query, saturating PHP-FPM workers. Mitigations include probabilistic early expiration, stale-while-revalidate, and mutex locks so only one worker rebuilds while others wait or serve stale data. Laravel's Cache::lock() with a short TTL works well: block briefly on rebuild, then release in a finally block so a crashed worker does not block rebuilds forever.

Commit the database transaction first, then delete or tag-flush application cache keys in Redis, then dispatch CDN purge for affected URLs or surrogate keys. Purging CDN before the database commit can cache error pages or old content twice. If templates changed, bump the view or config version after cache clears. Log invalidation events with key names, tags, actor, and timestamp for debugging.

Cache::flush() on Redis wipes the entire database, not just your application keys, if multiple apps share one instance. That is a production incident waiting to happen. Prefer explicit key deletion or tag-based flush with namespaced prefixes instead. Under-invalidation is quieter but worse: caching a lawyer directory filtered by city and practice area but only flushing on profile edit leaves stale list pages when a city slug changes. Trace every cached response back to its inputs to define your tag set.

WordPress with Redis object cache invalidates on save_post, edited_term, and plugin-specific hooks. WooCommerce 11.1 clears product transients on product save, but custom queries you add still need manual clears. The patterns transfer from Laravel even though APIs differ: invalidate on write, use tags or explicit keys, and walk the multi-layer sequence of database commit, Redis clear, then CDN purge. Plugin hooks replace Laravel observers as the trigger point.

Teams cache aggressively but skip the invalidation map until something breaks. Repeated failures include cache stampedes after mass tag flush, forgetting CDN and opcode layers while only clearing Redis, deploying Blade template changes without bumping cache key versions, and either flushing too much with Cache::flush() or too little by missing list-dimension tags. Invalidating Redis does nothing for a Cloudflare-cached HTML page where the edge still serves yesterday's markup while your app returns fresh JSON.

Cache category trees and static filters with tag invalidation on catalog edits, but never cache personalised prices or cart totals under shared keys. Stock counts often skip caching or use very short TTL plus event invalidation on order placement. Invalidate list tags when any product in that list changes, not only detail keys. Separate cache namespaces for admin previews versus public storefront. Payment gateway callbacks need idempotent cache clears: store processed webhook IDs in Redis with a TTL so duplicate webhooks do not double-flush or skip flush due to race logic.

Redis and application cache sit behind your PHP layer, but CDN edge nodes cache rendered HTML and static responses independently. Clearing Redis leaves Cloudflare or similar edges serving stale pages even when Laravel returns fresh data. After content publishes, call your CDN purge API with URL lists or cache tags. Pair purges with Cache-Control headers matching your strategy: static assets with hashed filenames can live forever, but HTML should not. API bypass headers apply the same principle for JSON endpoints.

Add a store_version value in config or .env such as CACHE_VERSION=2026091201, then prefix cache keys with that version. When you deploy a Blade template change, bump the version in your release hook. Old keys become orphans and expire naturally without a manual Redis flush at 2 a.m. Sites deployed with Deployer 7 use this pattern in the release hook. Without a version dimension, fragment cache keys stay identical after template changes and users see old HTML wrapped around new CSS or broken layouts.

Write feature tests that assert keys are cleared after updates. Create a model, warm Cache::remember with a known key, confirm Cache::has returns true, update the record, then assert Cache::has returns false. That catches observers removed during refactors. Pair unit tests with integration tests on staging workflows before major releases. Enable logging of cache misses and invalidation events at info level rather than Redis MONITOR in production. Track hit ratio via APM or a daily Artisan command so you can see invalidation gaps before users report stale data.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: