Speeding Up Laravel Tests with tmpfs (MySQL in RAM)
If your Laravel test suite is painfully slow and does a lot of database work (multi-tenancy, migrations, seeding), the bottleneck is almost…
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.
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.
Cache tags only work with Redis and Memcached. If you're using:
Your application will throw exceptions. This creates a nasty surprise when switching environments or cache drivers.
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.
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
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();
}
}
}
}
// 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();
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);
}
}
The example above includes critical improvements to avoid common pitfalls:
The trackCacheKey() method uses Cache::lock() to prevent race conditions when multiple requests try to update the keys list simultaneously.
The forget() method removes keys from both the cache AND the tracking list, preventing memory leaks from stale key references.
The getMany() method uses Cache::many() and Cache::putMany() for efficient bulk operations instead of making individual cache calls in loops.
The key tracking approach has a small overhead:
For most applications, this overhead is negligible compared to the benefits of reliability and cross-driver compatibility.
Cache tags have significant limitations:
=> 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.
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