Introduction
Cache invalidation is famously one of the two hard problems in computer science (alongside naming things and off-by-one errors). For Laravel developers using Redis, the challenge isn't just storing data—it's knowing precisely when and how to remove or update cached entries so users never see stale information while still reaping massive performance gains.
In this comprehensive guide, we'll explore five battle-tested Redis cache invalidation strategies tailored for Laravel applications. We'll move from basic time-based expiration to sophisticated event-driven and tag-based systems, complete with production-ready code examples you can adapt immediately. Whether you're building a high-traffic e-commerce platform, a real-time dashboard, or a content-heavy CMS, these patterns will help you maintain cache consistency without sacrificing throughput.
Table of Contents
- Introduction
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
What Is Cache Invalidation?
Cache invalidation is the process of marking cached data as outdated so that subsequent requests fetch fresh data from the primary data store (typically a database) and repopulate the cache. Without invalidation, caches serve stale data indefinitely, leading to inconsistencies between what users see and the actual system state.
Why Redis for Laravel Caching?
Redis excels as a Laravel cache backend due to its sub-millisecond latency, rich data structures (strings, hashes, sets, sorted sets), built-in TTL support, pub/sub messaging, and atomic operations. Laravel's Cache facade provides a unified API over Redis, but understanding Redis primitives lets you implement invalidation strategies that go beyond the facade's defaults.
Key Invalidation Dimensions
- Granularity: Single key, key pattern, tag group, or entire cache namespace.
- Trigger: Time (TTL), explicit application event, database change, external webhook, or manual admin action.
- Consistency Model: Eventual (acceptable staleness window) vs. strong (immediate invalidation on write).
- Scope: Local (single server) vs. distributed (multiple app servers sharing Redis).
Architecture Overview
A typical Laravel + Redis caching stack consists of:
- Application Layer: Laravel controllers, services, jobs, and commands that read/write via
Cache::facade orRedis::facade. - Cache Layer: Redis instance(s) — standalone, sentinel, or cluster — storing serialized PHP values (usually JSON) with optional metadata.
- Invalidation Triggers: Model observers, event listeners, scheduled commands, middleware, or external webhook handlers that call invalidation logic.
- Observability: Logging, metrics (cache hit/miss ratios, invalidation latency), and debugging endpoints.
The strategies below differ primarily in where invalidation logic lives and how triggers propagate to Redis.
Step-by-Step Guide
Strategy 1: Time-Based Expiration (TTL)
The simplest approach: assign a time-to-live to every cache entry. Redis automatically evicts expired keys. No explicit invalidation code needed.
- Choose a TTL based on data volatility (e.g., 60 seconds for real-time dashboards, 1 hour for product catalogs).
- Set TTL at write time:
Cache::put('key', $value, $seconds)orCache::remember('key', $seconds, fn() => $value). - Accept that data may be stale up to TTL seconds.
Strategy 2: Explicit Key Deletion
Delete specific keys when you know the underlying data changed.
- Define a predictable key naming convention:
products:{id},user:{id}:profile. - On model update/delete, call
Cache::forget('products:' . $product->id). - For bulk updates, use
Cache::flush()(nuclear option) or iterate keys.
Strategy 3: Cache Tags (Laravel's Built-In Tagging)
Laravel's cache tagging (supported by Redis, Memcached, DynamoDB) groups related keys under a tag. Flushing a tag invalidates all keys bearing that tag.
- Tag writes:
Cache::tags(['products', 'category:' . $categoryId])->put('key', $value, $ttl). - Invalidate by tag:
Cache::tags(['products'])->flush()clears all product-related keys. - Combine tags for fine-grained control:
Cache::tags(['products', 'featured'])->flush().
Strategy 4: Event-Driven Invalidation with Redis Pub/Sub
For distributed deployments, use Redis pub/sub to broadcast invalidation messages to all app servers instantly.
- Publish invalidation events:
Redis::publish('cache.invalidate', json_encode(['tags' => ['products'], 'keys' => ['products:123']])). - Subscribe in a long-running artisan command or Laravel Echo server worker.
- On message, call
Cache::tags(...)->flush()orCache::forget(...)locally.
Strategy 5: Hybrid Versioned Keys (Cache Busting)
Append a version number or hash to cache keys. Increment the version on data change—old keys become orphaned and eventually expire.
- Store a version counter in Redis:
Redis::incr('cache.version.products'). - Read version at cache key generation:
$v = Redis::get('cache.version.products'); $key = "products:v{$v}:list";. - On product change, increment version. New reads get new key; old keys expire naturally.
Real-World Examples
E-Commerce Product Catalog
Product prices, stock, and descriptions change frequently. Use Strategy 3 (tags) for category pages and Strategy 5 (versioned keys) for individual product detail pages. Tag flush on bulk price updates; version bump on single product edits.
Real-Time Analytics Dashboard
Dashboard widgets aggregate millions of rows. Use Strategy 1 (short TTL: 30s) for near-real-time feel. For critical KPIs, combine with Strategy 4 (pub/sub) to push updates when underlying events occur (new order, sign-up).
Multi-Tenant SaaS Application
Each tenant's data must be isolated. Prefix all keys with tenant:{id}:. Use Strategy 2 (explicit deletion) on tenant-specific writes. For cross-tenant features (global search index), use Strategy 3 (tags: 'global-search').
Production Code Examples
Base Cache Service Class
cache = Cache::store('redis'); $this->prefix = $prefix ? "{$prefix}:" : ''; } protected function key(string $key): string { return $this->prefix . $key; } // Strategy 1: TTL public function remember(string $key, int $ttl, callable $callback) { return $this->cache->remember($this->key($key), $ttl, $callback); } // Strategy 2: Explicit deletion public function forget(string $key): bool { return $this->cache->forget($this->key($key)); } public function forgetMultiple(array $keys): int { $prefixed = array_map([$this, 'key'], $keys); return $this->cache->getStore()->getRedis()->del(...$prefixed); } // Strategy 3: Tags public function tag(array $tags): self { $this->cache = $this->cache->tags($tags); return $this; } public function flushTags(array $tags): void { Cache::tags($tags)->flush(); } // Strategy 4: Pub/Sub invalidation public function broadcastInvalidation(array $tags = [], array $keys = []): void { $payload = json_encode([ 'tags' => $tags, 'keys' => array_map([$this, 'key'], $keys), 'timestamp' => now()->toIso8601String(), ]); Redis::publish('cache.invalidate', $payload); } // Strategy 5: Versioned keys public function getVersion(string $namespace): int { return (int) Redis::get("cache.version:{$namespace}") ?? 1; } public function incrementVersion(string $namespace): int { return Redis::incr("cache.version:{$namespace}"); } public function versionedKey(string $namespace, string $key): string { $version = $this->getVersion($namespace); return $this->key("{$namespace}:v{$version}:{$key}"); }}Model Observer Integration
cache->forget("products:{$product->id}"); // Strategy 3: Flush category tag $this->cache->flushTags(["category:{$product->category_id}", 'products']); // Strategy 4: Broadcast to other servers $this->cache->broadcastInvalidation( tags: ["category:{$product->category_id}", 'products'], keys: ["products:{$product->id}"] ); // Strategy 5: Bump version for versioned keys $this->cache->incrementVersion('products'); } public function deleted(Product $product): void { $this->saved($product); // same invalidation logic }}Pub/Sub Listener Command
info('Starting cache invalidation listener...'); Redis::subscribe(['cache.invalidate'], function ($message) { $payload = json_decode($message, true); if (!$payload) { $this->warn('Invalid JSON payload: ' . $message); return; } // Flush tags if (!empty($payload['tags'])) { Cache::tags($payload['tags'])->flush(); $this->line("Flushed tags: " . implode(', ', $payload['tags'])); } // Forget specific keys if (!empty($payload['keys'])) { foreach ($payload['keys'] as $key) { Cache::forget($key); } $this->line("Forgot keys: " . implode(', ', $payload['keys'])); } }); return self::SUCCESS; }}Versioned Key Helper Trait
cache = app(CacheService::class); } protected function versionedCacheKey(string $namespace, string $key): string { return $this->cache->versionedKey($namespace, $key); } protected function bumpCacheVersion(string $namespace): void { $this->cache->incrementVersion($namespace); }}Controller Usage Example
cache->remember( 'products:list:' . request()->query('category', 'all'), 300, fn() => Product::with('category')->latest()->paginate(20) ); return response()->json($products); } public function show(Product $product): JsonResponse { // Strategy 5: Versioned key for product detail $key = $this->cache->versionedKey('products', "detail:{$product->id}"); $productData = $this->cache->remember( $key, 3600, // 1 hour base TTL fn() => $product->load(['variants', 'reviews.user'])->toArray() ); return response()->json($productData); } public function update(Product $product): JsonResponse { $product->update(request()->validate([ 'name' => 'required|string', 'price' => 'required|numeric|min:0', 'stock' => 'required|integer|min:0', ])); // Observer handles invalidation automatically return response()->json($product->fresh()); }}Comparison Table
| Strategy | Consistency | Complexity | Latency Impact | Distributed Safe | Best For |
|---|---|---|---|---|---|
| TTL Only | Eventual (up to TTL) | Low | None | Yes | High volatility, acceptable staleness |
| Explicit Key Deletion | Strong (immediate) | Low | Minimal | Yes (if single key) | Predictable key patterns, low cardinality |
| Cache Tags | Strong (immediate) | Medium | Low (tag lookup) | Yes | Grouped invalidation, category-based data |
| Pub/Sub Event-Driven | Strong (sub-second) | High | Network hop | Yes (designed for it) | Multi-server, real-time requirements |
| Versioned Keys | Strong (immediate) | Medium | Extra Redis GET | Yes | High read throughput, infrequent writes |
Best Practices
- Name keys systematically: Use colons as separators (
tenant:5:product:123) for pattern-based operations and readability. - Set sensible default TTLs: Even with explicit invalidation, always set a fallback TTL (e.g., 24h) to prevent orphaned keys.
- Invalidate at the source: Use model observers or repository methods, not controllers, to ensure invalidation runs regardless of entry point (API, CLI, jobs).
- Batch invalidations: For bulk operations, collect tags/keys and flush once per transaction via
DB::afterCommit(). - Monitor hit ratios: Track
cache.hitsvscache.missesin your APM (Laravel Telescope, Datadog, New Relic). Aim for >90% hit rate. - Test invalidation logic: Write feature tests that assert cache keys are flushed after model changes. Use
Cache::shouldReceive()for unit tests. - Graceful degradation: If Redis is unavailable, catch
RedisExceptionand fall back to database (or array cache driver) without breaking the request.
Common Mistakes
- Forgetting TTL on tagged keys: Tags don't expire automatically. Always combine tags with TTL.
- Over-using
Cache::flush(): This clears everything (sessions, rate limiters, other apps sharing Redis). Use tags or targeted deletes. - Race conditions on versioned keys: Between reading version and writing key, another process may increment version. Use Lua scripts for atomic check-and-set if critical.
- Pub/Sub message loss: Redis pub/sub is fire-and-forget. If a server is down, it misses invalidation. Combine with versioned keys or periodic reconciliation job.
- Caching heavy objects directly: Serialize only necessary data (arrays/JSON), not Eloquent models with relationships (causes N+1 on retrieval).
- Ignoring cache stampede: When a hot key expires, many requests hit DB simultaneously. Use
Cache::lock()or probabilistic early expiration.
Performance Tips
- Pipeline bulk operations: Use
Redis::pipeline()for multi-key deletes or version increments. - Compress large values:
gzcompress(json_encode($data))before storing;json_decode(gzuncompress($value))on read. Redis 7+ supports native compression modules. - Use hash structures for related fields: Instead of
user:1:profile+user:1:settings, storeHSET user:1 profile '{"name":...}' settings '{"theme":"dark"}'. Invalidate with singleDEL user:1. - Leverage Redis SCAN instead of KEYS: Never use
KEYS *in production. UseSCANcursor iteration for pattern-based cleanup. - Connection pooling: Configure
database.redis.client = 'predis'orphprediswith persistent connections inconfig/database.php.
Security Considerations
- Never cache sensitive data without encryption: Use Laravel's
encrypt()/decrypt()or a dedicated envelope encryption package before storing PII, tokens, or secrets in Redis. - Restrict Redis network access: Bind Redis to private VPC/subnet. Require AUTH password. Disable dangerous commands (
FLUSHALL,CONFIG) viarename-commandin redis.conf. - Validate cache keys: Prevent injection by whitelisting key prefixes. Never interpolate raw user input into cache keys.
- Rate-limit cache writes: Malicious users could fill Redis by triggering unique cache keys. Implement per-IP/IP-range write limits.
Deployment Notes
- Redis persistence: For cache-only workloads, disable RDB/AOF persistence (
save "",appendonly no) to maximize memory and speed. Accept cold-cache on restart. - Memory policy: Set
maxmemory-policy allkeys-lruso Redis evicts least-recently-used keys when full, preventing OOM crashes. - Laravel Octane compatibility: If using Octane (Swoole/RoadRunner), ensure CacheService is bound as singleton or re-resolved per request to avoid stale Redis connections.
- Health checks: Expose a route that pings Redis (
Redis::ping()) for load balancer health checks. - Blue-green deployments: Versioned keys survive deployments seamlessly. Tag-based invalidation may need coordination if tag namespace changes.
Debugging Tips
- Laravel Telescope: Install
laravel/telescopeto inspect cache hits/misses, tags, and TTLs in real time. - Redis CLI monitoring:
redis-cli MONITORshows live commands. Filter withgrep -i cache. - Slow log: Enable
slowlog-log-slower-than 10000(10ms) to catch expensive operations. - Custom artisan command: Build
cache:inspectto dump key metadata (TTL, size, type) for a given pattern. - Log invalidation events: In observer/listener, log
info('Cache invalidated', ['tags' => $tags, 'keys' => $keys])for audit trail.
FAQ
How do I choose the right TTL for my data?
Start with the maximum acceptable staleness for each data type. User-facing content (product prices, stock) often tolerates 60–300 seconds. Configuration/settings can use hours. Measure actual change frequency and adjust. A/B test TTL values against hit-rate metrics.
Can I use cache tags with Redis Cluster?
Laravel's tag implementation uses a separate Redis set per tag to track member keys. This works with Redis Cluster only if all tag keys and member keys hash to the same slot. Use hash tags (e.g., {cache}:tag:products) to force co-location, or avoid tags in clustered setups.
What happens if the pub/sub listener crashes?
Missed invalidation messages are lost. Mitigate by: (1) running multiple listener replicas via Supervisor, (2) combining with versioned keys so stale data eventually expires, (3) scheduling a periodic reconciliation job that re-validates critical caches against DB.
Is it safe to cache Eloquent models directly?
Caching models with loaded relationships serializes the entire object graph, including pivot data and query builders. On retrieval, you get a detached model that won't lazily load relationships. Prefer caching toArray() or toJson() output, or use dedicated resource classes.
How do I handle cache invalidation across multiple Redis databases?
Laravel's Redis facade uses a single connection (default DB 0). If you shard across databases, you'll need separate cache store configurations in config/cache.php and explicit store selection: Cache::store('redis-db1')->tags(...)->flush().
Can I invalidate cache from a database trigger?
Not directly. Database triggers run inside the DB transaction and cannot call external services reliably. Instead, use Laravel model events (which fire after commit via DB::afterCommit) or a change-data-capture tool (Debezium) that publishes to Redis/Kafka.
What's the performance overhead of cache tags?
Each tagged write adds a SADD to the tag's member set. Each tag flush iterates members and deletes them. For high-write, high-cardinality tags, this can become a bottleneck. Benchmark with realistic load; consider versioned keys as alternative.
How do I warm the cache after deployment?
Create a cache:warm command that pre-populates critical keys (homepage, navigation, top products). Run it in your deployment pipeline after migrations but before switching traffic. Use Cache::rememberForever() for keys that never change without explicit invalidation.
Conclusion
Effective Redis cache invalidation in Laravel isn't about picking a single strategy—it's about composing the right mix for each data domain. Start with TTL as your safety net, layer explicit key deletion for predictable updates, adopt tags for grouped invalidation, and graduate to pub/sub or versioned keys when distributed consistency and read throughput demand it.
The production code examples above give you a battle-tested foundation. Adapt the CacheService to your key taxonomy, register the observer on your core models, deploy the listener command via Supervisor, and monitor hit ratios relentlessly. Your users will feel the speed; your database will thank you for the reduced load.
Ready to level up your Laravel caching? Implement one strategy today, measure the impact, and iterate. Share your invalidation war stories or questions in the comments below—let's build faster, more consistent applications together.