Making Laravel Cache Tags More Useful

Laravel is the PHP framework that I use for most of my projects, because out of the box, it allows for some of the most expressive code that gets out of my way and gets the job done. However recently, I found a deficiency in the way that the Laravel Cache system handles tags and decided to come up with a fix for my personal use case.

If I asked you what the output of the following code would be, what would you say?

Cache::tags([ 'abc', 'def' ])->put('key', 'value', $lifetime = 10);

dd(
	Cache::has('key'),
	Cache::tags('abc')->has('key'),
	Cache::tags('def')->has('key'),
	Cache::tags(['def','abc'])->has('key')
);

If you guessed true for any of the values above, you'd be wrong. That's because the way that ::tags works is, in all reality, more like a folder system than a traditional tagging system - this allows you to re-use the same cache key with multiple tags without fear of inadvertently overwriting an existing tag.

I needed the ability to see if a cache key existed in any of the tagged values, so I created the two macros that are shown at the bottom of this post. Place them in app/Providers/AppServiceProvider.php in the boot method somewhere. In code, you'll then be able to call

$variable = Cache::getTagged('key', [ 'ghi' ]) ?? Cache::tags([ 'abc', 'def', 'ghi' ])->remember('key', $lifetime = 10, function () { return time(); });

and get back the value of 'key' from the cache, if it's tagged with any of the passed tags, and execute the remember closure if it doesn't.

This likely will cause more traffic against your Redis server, and could increase memory usage because of the instantiation of multiple objects - though it's probably still viable for most everyone. Note: this is only built and tested against Redis (and only against remember items, not rememberForever) - making it work against other TaggableStore objects in Laravel is left as an exercise to the reader.

Cache::macro('tagged', function (...$tags) {
    $tags = Arr::wrap($tags);

    $tagged = collect();
    foreach ($tags as $tag) {
        $tagSet = new \Illuminate\Cache\TagSet($this->getStore(), Arr::wrap($tag));
        $tagCache = new \Illuminate\Cache\RedisTaggedCache($this->getStore(), $tagSet);

        $key = $tagCache->referenceKey(
            $tagSet->getNamespace(),
            \Illuminate\Cache\RedisTaggedCache::REFERENCE_KEY_STANDARD
        );

        $tagged->push(
            collect($this->store->connection()->smembers($key))
                ->map(function ($key) {
                    return Str::after($key, $this->getStore()->getPrefix());
                })
                ->toArray()
        );
    }

    return $tagged->flatten()->unique();
});

Cache::macro('getTagged', function (string $key, array $tags) {
    foreach ($tags as $tag) {
        $matchingKey = Cache::tagged($tag)
            ->filter(function ($existingCacheKey) use ($tag, $key) {
                return Str::endsWith($existingCacheKey, $key);
            })
            ->first();

        if ($matchingKey !== null) {
            return $this->store->get($matchingKey);
        }
    }

    // If we're still here at this point, this key doesn't exist
    return null;
});