Why Laravel's Cache Tags Are Broken (And How I'm Fixing Them)
I hit a wall recently. Not the kind you can power through with another cup of coffee, but the kind that makes you question fundamental design decisions in the tools you use every day.
I'm building a Privileged Access Management (PAM) system—80% done, just wrapping up the RBAC (Role-Based Access Control) implementation. Everything was going smoothly until I faced the caching layer. In a permission system, caching isn't optional. Without it, every request hammers your database with permission checks. DB permission checks can be a problem as applications scale.
Then I discovered that Laravel's cache tag system is fundamentally broken.
The Problem: Tags That Don't Work Like Tags
Here's what Laravel forces you to do:
// Setting cache with tags
Cache::tags(['users', 'permissions'])->put('user.1.permissions', $data);
// Retrieving requires THE EXACT SAME TAGS
Cache::tags(['users', 'permissions'])->get('user.1.permissions');
// Forget even ONE tag? Cache miss.
Cache::tags(['users'])->get('user.1.permissions'); // Returns NULL!
Read that again. You must remember and provide the exact same tag combination used when setting the cache, or you get nothing back.
This isn't how tags work anywhere else. Tags are metadata. They're labels you attach to things so you can find groups of related items later. You don't need to remember which tags you used—that's the whole point of tagging systems.
But Laravel's implementation treats tags as part of the retrieval key. Miss one tag? Cache miss. Wrong order? Probably a cache miss. It's not just unfriendly—it's actively hostile to how developers think about categorization.
And the kicker? This only works with Redis and Memcached. File and array drivers? No tag support at all.
Why This Matters for RBAC
In a permission system, you need to cache:
- User role assignments
- Permission mappings
- Policy evaluation results
- Role hierarchies and inheritance chains
These caches have complex relationships. When a role changes, you need to invalidate all users with that role. When a permission changes, you might need to flush everything. When a specific user's roles change, you want surgical precision—just that user's caches.
Tags should make this easy:
// Tag with user, role, and permission identifiers
CacheDependency::tags(['user.123', 'role.5', 'permissions', 'rbac'])
    ->put('user.123.effective.permissions', $permissions);
// Later, invalidate all RBAC caches
CacheDependency::invalidateTags('rbac');
// Or just this user
CacheDependency::invalidateTags('user.123');
// Or all permission-related caches
CacheDependency::invalidateTags('permissions');
With Laravel's built-in system, this is impossible. You'd need to track every tag combination you used, pass them all on retrieval, and hope you didn't miss any.
The Yii2 Inspiration
I've built large-scale applications in Yii2 before, and this problem doesn't exist there. Yii2's cache dependency system is elegant:
// Yii2 approach
$dependency = new TagDependency(['tags' => ['user-123', 'permissions']]);
$data = Yii::$app->cache->get('user.123.permissions', $dependency);
// Invalidate by tag - simple
TagDependency::invalidate(Yii::$app->cache, 'permissions');
You set dependencies when caching. You retrieve normally. You invalidate by tag. It just works.
Yii2 also had database dependencies—automatic invalidation when database records change. Perfect for data-driven applications where cache freshness is critical.
How Yii2 Dependencies Work Under the Hood
Understanding Yii2's implementation reveals why it works so well:
Tag Dependencies:
When you cache data with a TagDependency, Yii2 stores two things:
- The actual cached data at your specified key (e.g., user.123.permissions)
- A separate version counter for each tag (e.g., permissions:version→42)
The dependency object stores which version it was created with. When you retrieve the cache:
- Yii2 checks the current version of all associated tags
- If any tag version has incremented, the cache is considered stale
- The cached data is ignored and regenerated
When you invalidate a tag:
- Yii2 simply increments that tag's version counter
- All caches associated with that tag become instantly stale
- No need to find and delete individual cache entries
This is brilliant because:
- Retrieval doesn't need to know the tags—the dependency object handles it
- Invalidation is O(1)—just increment a counter
- Works with any cache driver that supports get/set
- No complex tag-to-key mappings to maintain
Database Dependencies:
DbDependency is even more interesting:
$dependency = new DbDependency([
    'sql' => 'SELECT MAX(updated_at) FROM users WHERE id = :id',
    'params' => [':id' => 123]
]);
$data = Yii::$app->cache->get('user.123.data', $dependency);
When caching with a DbDependency:
- Yii2 executes your SQL query and gets a result (e.g., 2024-10-28 10:30:00)
- Stores this baseline value alongside your cached data
- Serializes the entire dependency object with the cache
On retrieval:
- Yii2 deserializes the dependency
- Re-executes the same SQL query
- Compares the new result with the stored baseline
- If different → cache miss, regenerate data
- If same → cache hit, return cached data
This means:
- Cache automatically invalidates when database records change
- No manual invalidation hooks needed
- The SQL query itself defines the dependency condition
- Works with aggregates, timestamps, version columns—anything
Why This Architecture Matters:
Both approaches share a key insight: dependencies are metadata that travel with the cached data. You don't need to remember them during retrieval because they're already there, embedded in the cache entry itself.
This is fundamentally different from Laravel's approach where tags are part of the storage key structure, making retrieval dependent on providing the exact same tags.
I wanted this in Laravel.
The Solution: A New CacheDependency Facade
Rather than fighting with Laravel's Cache facade, I decided to build something new that works correctly from the ground up. A separate facade that provides dependency-based caching without breaking Laravel's existing behavior.
Here's what it looks like:
Tag Dependencies That Actually Work
use CacheDependency;
// Set cache with tag dependency
CacheDependency::tags(['users', 'roles'])
    ->put('user.1.permissions', $permissions);
// Retrieve normally - NO TAGS REQUIRED!
$permissions = CacheDependency::get('user.1.permissions');
// Or use standard Cache facade - works either way
$permissions = Cache::get('user.1.permissions');
// Invalidate all caches tagged with 'roles'
CacheDependency::invalidateTags('roles');
Tags are set once, stored as metadata alongside your cached value. Retrieval doesn't need them. Invalidation uses them. This is how tags should work.
Database Dependencies: Automatic Freshness
Even better, database dependencies automatically invalidate caches when data changes:
// Cache invalidates when roles table changes
CacheDependency::db('SELECT MAX(updated_at) FROM roles')
    ->remember('all.roles', 3600, fn() => Role::all());
// Cache invalidates when user's roles change
CacheDependency::db(
    "SELECT updated_at FROM role_user WHERE user_id = ?", 
    [$userId]
)->remember("user.{$userId}.roles", fn() => User::find($userId)->roles);
Behind the scenes, the system stores the initial query result (like a timestamp) with your cached data. On retrieval, it re-runs the query. If the result changed, the cache is automatically regenerated. No manual invalidation needed.
This is perfect for RBAC scenarios where database changes should immediately affect permission caches. Security-critical data staying fresh without manual cache management.
Why a Separate Facade?
I considered extending Laravel's Cache facade but decided against it. Here's why:
Clarity of intent: When you see CacheDependency::, you know you're using dependency features. It's explicit.
No conflicts: Your existing Cache usage keeps working. No risk of breaking changes.
Easier maintenance: Independent of Laravel's cache internals. If Laravel changes how caching works, this package adapts separately.
Better testing: Isolated from Laravel's cache system means cleaner, more focused tests.
Universal driver support: Works with File, Redis, Memcached, Database, and Array drivers—not just Redis/Memcached like Laravel's tags.
Current Status and What's Next
I'm in the exploration phase. The PAM system development revealed these caching limitations as I dove deeper into RBAC. The patterns I've outlined are based on Yii2's proven approach, but they need validation in Laravel's ecosystem.
Key questions to answer:
- Performance overhead of storing dependency metadata with cached values?
- Will tag versioning work efficiently across all cache drivers?
- What edge cases exist in database dependency checking?
The Bigger Picture
Laravel's cache tag API doesn't work the way developers expect tags to work. When you encounter a design that fights you, it's worth asking: "What would this look like if it were built right?"
I'm thinking through these problems now, exploring what a proper solution might look like. Drawing from Yii2's proven patterns, adapting them to Laravel's ecosystem.
