Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Do Not Use Laravel Cache Tags (And What You Should Use Instead)

Laravel's cache tags feature only works with Redis and Memcached. Using it with other cache drivers (file, database, DynamoDB, array) will throw exceptions. This creates problems when switching between environments or cache backends.

Cache Tags Were Removed from Laravel Documentation

In Laravel 10, cache tags were removed from the official documentation. Taylor Otwell's commit message stated: "undocument cache tags due to complexity of implementation".

Laravel typically removes features from documentation before deprecating them in the framework.

The Problems with Cache Tags

1. Limited Driver Support

Cache tags only work with Redis and Memcached. If you're using:

  • File cache (default for many developers)
  • Database cache
  • DynamoDB
  • Array cache (for testing)

Your application will throw exceptions. This creates a nasty surprise when switching environments or cache drivers.

2. Confusing and Inconsistent Behavior

The tag implementation has surprising behavior that defies expectations. Consider this example:

// Store with two tags
Cache::tags(['products', 'electronics'])->put('laptop-123', $laptopData, 3600);

// Try to retrieve - order matters!
Cache::tags(['products', 'electronics'])->get('laptop-123'); // Returns data
Cache::tags(['electronics', 'products'])->get('laptop-123'); // Returns null!

// Single tag doesn't work either
Cache::tags(['products'])->get('laptop-123'); // Returns null
Cache::tags(['electronics'])->get('laptop-123'); // Returns null

// Flushing is equally confusing
Cache::tags(['products'])->flush(); // Doesn't delete the item!
Cache::tags(['products', 'electronics'])->flush(); // This works
Cache::tags(['electronics', 'products'])->flush(); // This doesn't!

This behavior is counterintuitive and leads to hard-to-debug issues.

3. Memory Leaks

When you flush a single tag from a multi-tagged cache entry, Laravel doesn't properly clean up the references to other tags. This creates "garbage" in your cache that accumulates over time:

// Store with multiple tags
Cache::tags(['users', 'admins'])->put('user-456', $userData, 3600);

// Flush only one tag
Cache::tags(['users'])->flush();

// The 'admins' tag still has a reference to a non-existent cache entry!
// This garbage accumulates and wastes memory

The Better Alternative: Manual Key Tracking

Instead of relying on cache tags, implement a simple key tracking system. Here's a robust pattern that works with ALL cache drivers:

class ProductCacheService
{
    const CACHE_PREFIX = 'products:';
    const CACHE_KEYS_LIST = 'products:_keys_list';
    const KEYS_LIST_LOCK = 'products:_keys_list_lock';
    const CACHE_DURATION = 3600; // 1 hour

    /**
     * Get a product from cache
     */
    public static function get(int $productId): ?Product
    {
        $cacheKey = self::CACHE_PREFIX . $productId;

        return Cache::remember($cacheKey, self::CACHE_DURATION, function() use ($productId, $cacheKey) {
            // Track this key for bulk operations
            self::trackCacheKey($cacheKey);

            return Product::find($productId);
        });
    }

    /**
     * Get multiple products efficiently
     */
    public static function getMany(array $productIds): array
    {
        // Build cache keys map
        $cacheKeyMap = [];
        $cacheKeysArray = [];
        foreach ($productIds as $id) {
            $cacheKey = self::CACHE_PREFIX . $id;
            $cacheKeyMap[$cacheKey] = $id;
            $cacheKeysArray[] = $cacheKey;
        }

        // Get all values from cache in one operation
        $cachedValues = Cache::many($cacheKeysArray);

        // Process results and identify missing
        $results = [];
        $missingIds = [];
        $toPut = [];

        foreach ($cachedValues as $cacheKey => $value) {
            $productId = $cacheKeyMap[$cacheKey];

            if ($value !== null) {
                $results[$productId] = $value;
            } else {
                $missingIds[] = $productId;
            }
        }

        // Fetch missing from database
        if (!empty($missingIds)) {
            $products = Product::whereIn('id', $missingIds)->get()->keyBy('id');

            foreach ($missingIds as $id) {
                $product = $products->get($id);
                $results[$id] = $product;
                $cacheKey = self::CACHE_PREFIX . $id;
                $toPut[$cacheKey] = $product;
            }

            // Cache all missing values in one operation
            if (!empty($toPut)) {
                Cache::putMany($toPut, self::CACHE_DURATION);

                // Track all new cache keys
                foreach (array_keys($toPut) as $cacheKey) {
                    self::trackCacheKey($cacheKey);
                }
            }
        }

        return $results;
    }

    /**
     * Clear a specific product from cache
     */
    public static function forget(int $productId): void
    {
        $cacheKey = self::CACHE_PREFIX . $productId;

        // Remove from cache
        Cache::forget($cacheKey);

        // Remove from tracking list with lock
        $lock = Cache::lock(self::KEYS_LIST_LOCK, 5);

        if ($lock->get()) {
            try {
                $keys = Cache::get(self::CACHE_KEYS_LIST, []);
                $keys = array_values(array_diff($keys, [$cacheKey]));
                Cache::put(self::CACHE_KEYS_LIST, $keys, self::CACHE_DURATION);
            } finally {
                $lock->release();
            }
        }
    }

    /**
     * Clear ALL product caches
     */
    public static function flush(): void
    {
        // Get all tracked keys
        $keys = Cache::get(self::CACHE_KEYS_LIST, []);

        // Delete each cached item
        foreach ($keys as $key) {
            Cache::forget($key);
        }

        // Clear the keys list
        Cache::forget(self::CACHE_KEYS_LIST);
    }

    /**
     * Track a cache key for bulk invalidation (thread-safe)
     */
    private static function trackCacheKey(string $key): void
    {
        // Use atomic lock to prevent race conditions
        $lock = Cache::lock(self::KEYS_LIST_LOCK, 5);

        if ($lock->get()) {
            try {
                $keys = Cache::get(self::CACHE_KEYS_LIST, []);

                if (!in_array($key, $keys)) {
                    $keys[] = $key;
                    Cache::put(self::CACHE_KEYS_LIST, $keys, self::CACHE_DURATION);
                }
            } finally {
                $lock->release();
            }
        }
    }
}

Usage Example

// Get a single product (automatic caching)
$product = ProductCacheService::get(123);

// Get products by category
$electronics = ProductCacheService::getByCategory('electronics');

// Clear specific product cache
ProductCacheService::forget(123);

// Clear ALL product caches (similar to tag flush)
ProductCacheService::flush();

Benefits of This Approach

  1. Works with ALL cache drivers - File, Redis, Memcached, Database, etc.
  2. Predictable behavior - No surprises with tag ordering
  3. Explicit and debuggable - You can inspect the keys list
  4. No memory leaks - Clean deletion of all references
  5. Easy to extend - Add more granular clearing methods as needed

When You Need More Complex Invalidation

If you need to invalidate caches based on multiple criteria, create multiple key lists:

class UserCacheService
{
    const CACHE_PREFIX = 'users:';
    const ALL_KEYS_LIST = 'users:_all_keys';
    const ROLE_KEYS_PREFIX = 'users:_role_keys:';

    public static function get(int $userId, string $role): ?User
    {
        $cacheKey = self::CACHE_PREFIX . $userId;

        return Cache::remember($cacheKey, 3600, function() use ($userId, $role, $cacheKey) {
            // Track in overall list
            self::trackCacheKey($cacheKey, self::ALL_KEYS_LIST);

            // Track by role for role-specific invalidation
            self::trackCacheKey($cacheKey, self::ROLE_KEYS_PREFIX . $role);

            return User::find($userId);
        });
    }

    public static function flushByRole(string $role): void
    {
        $keys = Cache::get(self::ROLE_KEYS_PREFIX . $role, []);

        foreach ($keys as $key) {
            Cache::forget($key);
        }

        Cache::forget(self::ROLE_KEYS_PREFIX . $role);
    }
}

Important Implementation Details

The example above includes critical improvements to avoid common pitfalls:

1. Atomic Operations with Locks

The trackCacheKey() method uses Cache::lock() to prevent race conditions when multiple requests try to update the keys list simultaneously.

2. Clean Key Removal

The forget() method removes keys from both the cache AND the tracking list, preventing memory leaks from stale key references.

3. Efficient Bulk Operations

The getMany() method uses Cache::many() and Cache::putMany() for efficient bulk operations instead of making individual cache calls in loops.

Performance Considerations

The key tracking approach has a small overhead:

  • One extra cache read/write per cache miss to update the keys list (protected by lock)
  • Linear time complexity for flush operations (iterating through keys)
  • Lock acquisition adds minimal latency (fails gracefully after 5 seconds)

For most applications, this overhead is negligible compared to the benefits of reliability and cross-driver compatibility.

Summing it up

Cache tags have significant limitations:

  • Only work with Redis and Memcached
  • Removed from Laravel 10+ documentation
  • Tag order affects functionality
  • Can cause memory leaks with partial flushing
  • Likely to be deprecated in future versions

=> The manual key tracking approach provides the same functionality while working with all cache drivers. This ensures your application remains portable across different cache backends.

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Subscribe to my newsletter

Get new posts when I publish them.

I respect your privacy. Unsubscribe at any time.

Leave a Reply

Your email address will not be published. Required fields are marked *