paulund

Cache Invalidation Strategies

Cache Invalidation Strategies

"There are only two hard problems in computer science: cache invalidation and naming things." Stale cache entries are a common source of subtle bugs. Laravel gives you several mechanisms to keep your cache in sync with the data it represents.

Tag-Based Invalidation

Cache tags are the most targeted invalidation tool Laravel provides. When you store a cached value, tag it with one or more labels. When the underlying data changes, flush only those tags:

// Storing — tag the cached article list
Cache::tags(['articles'])->put('article_list', $articles, 3600);

// Invalidating — when any article is created, updated, or deleted
Cache::tags(['articles'])->flush();

For finer granularity, tag by entity ID:

// Store a specific article's rendered page
Cache::tags(['articles', 'article:42'])->put('article:42:page', $html, 3600);

// Invalidate only article 42 when it is edited
Cache::tags(['article:42'])->flush();

Event-Driven Invalidation

Tie cache invalidation to Laravel's event system. This keeps your invalidation logic decoupled from your controllers and actions:

// app/Listeners/InvalidateArticleCache.php
class InvalidateArticleCache
{
    public function handle(ArticleUpdated $event): void
    {
        Cache::tags(['articles', "article:{$event->article->id}"])->flush();
    }
}

// Register in AppServiceProvider or via the #[Listen] attribute
use App\Events\ArticleUpdated;
use App\Listeners\InvalidateArticleCache;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(ArticleUpdated::class, InvalidateArticleCache::class);
    }
}

Now, whenever you fire an ArticleUpdated event anywhere in your codebase, the relevant cache entries are flushed automatically. No need to remember to clear the cache in every controller method.

TTL-Based Invalidation

For data that does not need to be perfectly fresh, simply let the cache entry expire. Set a TTL that matches how often the data changes:

// Product catalogue — updated daily, so cache for 1 hour
Cache::remember('products', 3600, fn() => Product::all());

// Homepage stats — updated every few minutes
Cache::remember('homepage_stats', 300, fn() => computeStats());

TTL-based invalidation is the simplest strategy and works well when eventual consistency is acceptable.

Model-Level Cache Invalidation

You can attach invalidation logic directly to your Eloquent model using the saved, deleted, and updated model events:

class Article extends Model
{
    protected static function booted(): void
    {
        static::saved(function (self $article) {
            Cache::tags(['articles', "article:{$article->id}"])->flush();
        });

        static::deleted(function (self $article) {
            Cache::tags(['articles', "article:{$article->id}"])->flush();
        });
    }
}

This ensures that any time an Article is saved or deleted — regardless of where in your code it happens — the cache stays in sync.

Cache-Busting for Assets

For frontend assets (CSS, JavaScript, images), cache-busting prevents browsers from serving stale files after a deployment. Laravel's asset() helper with Vite handles this automatically in development, and Vite's manifest file handles it in production:

<!-- Vite handles cache-busting via hashed filenames -->
@vite('resources/css/app.css')
@vite('resources/js/app.js')

Choosing the Right Strategy

Strategy Best for
Tag-based flushing Data that changes unpredictably; you need immediate freshness
Event-driven Decoupled invalidation across a large codebase
TTL expiry Data where slight staleness is acceptable
Model events Simple apps where cache logic lives close to the model
Asset cache-busting Static files served after deployments

Tips

  • Do not flush the entire cache when only a subset of data changed. Tags exist precisely to avoid this.
  • Test your invalidation logic. Write a test that creates or updates a record and asserts that the relevant cache key is no longer present.
  • In production, make sure your cache driver supports tags (Redis or Memcached). The file and database drivers do not.