Laravel Cache: Get All Items With a Tag (Working Fix)

By HeadsNTail Published 11 min read Laravel
Laravel Cache: Get All Items With a Tag (Working Fix)

The most common mistake developers make with Laravel cache tags is assuming that a tag behaves like a database table: store several values under one tag, then request the tag and expect an array containing everything.

That is not how Laravel's Cache API works.

Laravel cache tags let you group, retrieve, and invalidate cached entries, but they do not provide a public API for enumerating every key assigned to a tag.

If you need to retrieve all cached products, users, or records belonging to a particular tag, you must maintain a list of their keys or store the entire collection under one known key.

This tutorial demonstrates both solutions using a product catalog. You will reproduce the problem, configure a supported cache driver, build a reusable key registry, handle expired entries, and verify that the results are correct.

Step 1: Reproduce the Tagged Cache Problem

Suppose your Laravel application caches individual products.

You have two products:

Product IDNamePrice
101Keyboard75
102Mouse35

You want to cache each product separately while grouping them under a common tag named catalog.

Store two tagged entries

Open your Laravel project directory and run:

php artisan tinker

Inside Tinker, execute:

use Illuminate\Support\Facades\Cache;

$cache = Cache::store('redis')->tags(['ht-demo-catalog']);

$cache->put('product:101', [
    'id' => 101,
    'name' => 'Keyboard',
    'price' => 75,
], 600);

$cache->put('product:102', [
    'id' => 102,
    'name' => 'Mouse',
    'price' => 35,
], 600);

This example assumes Redis is already installed, configured, and accessible to Laravel. We will cover configuration in the next step.

Retrieve an individual product

Run:

$cache->get('product:101');

Expected result:

[
    'id' => 101,
    'name' => 'Keyboard',
    'price' => 75,
]

That works because Laravel knows the exact cache key.

Now try:

$cache->get('ht-demo-catalog');

Expected result:

null

Why?

Because ht-demo-catalog is a tag name, not a cache key containing the entire product collection.

Laravel does not automatically create a readable list of all items for every tag.

Understanding the difference

Your cache structure is conceptually:

Tag: ht-demo-catalog
    |
    +-- product:101 -> Keyboard
    |
    +-- product:102 -> Mouse

The tag connects these entries for namespace management and invalidation.

It does not turn the group into a collection that can be queried like an SQL result set.

Laravel's documented tagged-cache operations include storing values, retrieving known keys, and flushing tagged entries.

There is no documented getAll() or all() method for listing every member of a cache tag.

Reference: Laravel Cache Documentation.

Step 2: Confirm Your Cache Driver Supports Tags

Before implementing the solution, confirm which cache backend your application uses.

This is particularly important because many Laravel applications use the database or file cache driver by default.

Check the active cache configuration

Run:

php artisan tinker

Then:

config('cache.default');

Depending on your application configuration, the result might be:

database

or:

redis

Laravel cache tags are not supported by the file, database, DynamoDB, or storage cache drivers.

For the examples in this tutorial, we will explicitly use the Redis cache store.

Configure Redis

If Redis is not configured, install and configure an appropriate Redis client first.

Laravel supports the PhpRedis extension and the Predis PHP package.

If you use Predis, install it with:

composer require predis/predis

Then configure your .env file with settings matching your Redis server.

For a typical local Redis installation:

CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null

These values are examples, not universal production settings.

Use your actual Redis host, credentials, and networking configuration.

Clear the Laravel configuration cache if necessary:

php artisan config:clear

Confirm Redis is accessible

Run:

php artisan tinker

Then:

Cache::store('redis')->put('redis-test', 'working', 60);

Cache::store('redis')->get('redis-test');

Expected result:

"working"

This verifies that the Redis cache store can write and retrieve a known key.

Next, verify tagging:

Cache::store('redis')
    ->tags(['ht-demo-check'])
    ->put('status', 'tagging-works', 60);

Cache::store('redis')
    ->tags(['ht-demo-check'])
    ->get('status');

Expected result:

"tagging-works"

Clean up the temporary tagged entry:

Cache::store('redis')->tags(['ht-demo-check'])->flush();

Once these operations work, you can implement group retrieval.

Step 3: Use One Cache Key When You Need the Entire Collection

Before building a key registry, consider the simpler solution.

If your application always needs all products together, cache the entire collection under one key.

This avoids maintaining a separate index.

Store the complete product collection

In Tinker:

$products = [
    [
        'id' => 101,
        'name' => 'Keyboard',
        'price' => 75,
    ],
    [
        'id' => 102,
        'name' => 'Mouse',
        'price' => 35,
    ],
];

Cache::store('redis')
    ->tags(['ht-demo-catalog'])
    ->put('all-products', $products, 600);

Retrieve everything:

$products = Cache::store('redis')
    ->tags(['ht-demo-catalog'])
    ->get('all-products', []);

Now $products contains both entries.

Why this works

You are not asking Laravel to discover tagged cache keys.

Instead, you have created a known key named all-products containing an array.

The tag is still useful because you can invalidate that cached collection alongside other catalog entries.

When this approach is appropriate

I prefer this solution when:

  • The cached collection is reasonably small.
  • The application normally reads every item.
  • Items are updated infrequently.
  • Rebuilding the complete collection is inexpensive.
  • Individual item cache entries are unnecessary.

The limitation is that updating one product may require rebuilding or replacing the entire cached collection.

If you need separate cache entries with independent expiration times, continue with the next step.

Step 4: Build a Key Registry for Individually Cached Items

For individually cached entries, you need an explicit way to remember their keys.

The simplest reusable approach is maintaining an index alongside the actual cache values.

We will create a small Laravel service called CatalogTaggedCache.

Its responsibilities are:

  1. Cache an individual product.
  2. Record that product's cache key.
  3. Retrieve all registered products.
  4. Ignore and clean up expired entries.
  5. Remove individual products.
  6. Flush the complete tagged group.

Create the service class

Create:

app/Support/CatalogTaggedCache.php

Add the following code:

<?php

namespace App\Support;

use Illuminate\Support\Facades\Cache;

final class CatalogTaggedCache
{
    private const TAG = 'ht-demo-catalog';

    private const INDEX = '__catalog_keys';

    private const LOCK = 'ht-demo-catalog-registry';

    private function productKey(int $id): string
    {
        return 'product:' . $id;
    }

    public function put(int $id, array $product): void
    {
        Cache::store('redis')
            ->lock(self::LOCK, 10)
            ->block(5, function () use ($id, $product) {
                $cache = Cache::store('redis')
                    ->tags([self::TAG]);

                $key = $this->productKey($id);

                $cache->put($key, $product, 600);

                $keys = $cache->get(self::INDEX, []);

                $keys[$id] = $key;

                $cache->forever(self::INDEX, $keys);
            });
    }

    public function all(): array
    {
        return Cache::store('redis')
            ->lock(self::LOCK, 10)
            ->block(5, function () {
                $cache = Cache::store('redis')
                    ->tags([self::TAG]);

                $keys = $cache->get(self::INDEX, []);

                $items = [];

                $changed = false;

                foreach ($keys as $id => $key) {
                    $item = $cache->get($key);

                    if ($item === null) {
                        unset($keys[$id]);

                        $changed = true;

                        continue;
                    }

                    $items[$id] = $item;
                }

                if ($changed) {
                    $cache->forever(self::INDEX, $keys);
                }

                return $items;
            });
    }

    public function remove(int $id): void
    {
        Cache::store('redis')
            ->lock(self::LOCK, 10)
            ->block(5, function () use ($id) {
                $cache = Cache::store('redis')
                    ->tags([self::TAG]);

                $cache->forget($this->productKey($id));

                $keys = $cache->get(self::INDEX, []);

                unset($keys[$id]);

                $cache->forever(self::INDEX, $keys);
            });
    }

    public function flush(): void
    {
        Cache::store('redis')
            ->lock(self::LOCK, 10)
            ->block(5, function () {
                Cache::store('redis')
                    ->tags([self::TAG])
                    ->flush();
            });
    }
}

Why the registry works

The service maintains a separate index under the same tag.

Conceptually:

Tag: ht-demo-catalog

    product:101
        -> Keyboard

    product:102
        -> Mouse

    __catalog_keys
        -> [
             101 => "product:101",
             102 => "product:102"
           ]

When all() runs, it retrieves the index, reads each known key, and returns the cached values.

This is application-managed enumeration, not native enumeration provided by Laravel tags.

Why use a lock?

Imagine two requests storing products simultaneously.

Without coordination, both requests could:

  1. Read the current index.
  2. Add their own product.
  3. Write the index back.

The final write might overwrite the earlier update, causing one registered key to disappear.

The Redis-backed atomic lock helps serialize these registry updates.

Laravel provides the lock() and block() methods for this purpose.

The example uses a lock lifetime of 10 seconds and waits up to 5 seconds for acquisition.

A lock timeout throws an exception that production applications should handle appropriately.

The registry is still a cache, not a transactional database. Unexpected process failures, eviction, or external cache writes can leave its index incomplete.

For business-critical complete datasets, retrieve authoritative records from the database and treat this cache as an optimization.

Reference: Laravel Atomic Locks.

Step 5: Retrieve, Remove, and Flush Tagged Products

Now test the reusable service.

Open Tinker:

php artisan tinker

Create the service instance:

$catalog = app(\App\Support\CatalogTaggedCache::class);

For an isolated demonstration, clear this demo tag before inserting test products:

$catalog->flush();

Do not run that cleanup against a tag containing real application data.

Store products

$catalog->put(101, [
    'id' => 101,
    'name' => 'Keyboard',
    'price' => 75,
]);

$catalog->put(102, [
    'id' => 102,
    'name' => 'Mouse',
    'price' => 35,
]);

Retrieve everything:

$catalog->all();

Expected result:

[
    101 => [
        'id' => 101,
        'name' => 'Keyboard',
        'price' => 75,
    ],
    102 => [
        'id' => 102,
        'name' => 'Mouse',
        'price' => 35,
    ],
]

The returned array uses product IDs as keys.

Remove one product

Run:

$catalog->remove(101);

Then:

$catalog->all();

Only product 102 should remain.

The service removes both the individual cached value and its index entry.

Flush the entire catalog

Run:

$catalog->flush();

Then:

$catalog->all();

Expected result:

[]

This clears the tagged catalog group, including its stored index.

It is different from calling Cache::flush() on the full cache store, which may remove unrelated cached data.

What happens when entries expire?

Each product in this implementation has a 600-second lifetime.

The key index uses forever() so that it does not expire earlier than active product entries.

When a product expires, all() detects its missing value, removes its key from the registry, and returns the remaining items.

The index itself can still be evicted by the cache backend. If that happens, the service cannot rediscover entries that were not registered elsewhere.

Also, an index maintained forever should not grow without limit. This example prunes missing entries during reads, but large or rarely read catalogs need an additional cleanup strategy.

For large datasets, Redis sets, database-backed identifiers, or a carefully designed paging strategy may be more appropriate.

Step 6: Fix the Variations That Commonly Break Tagged Cache Retrieval

Most problems after implementing a registry come from cache-driver differences, inconsistent tags, stale keys, or assumptions about Redis internals.

Variation A: The selected driver does not support tags

If your application uses the file or database cache driver, tagged operations will fail because those stores do not support tagging.

Check:

config('cache.default');

If Redis is available, use it explicitly:

Cache::store('redis')
    ->tags(['ht-demo-catalog'])
    ->get('product:102');

Using an explicit store is useful when the rest of your Laravel application intentionally uses a different default cache driver.

Variation B: Multiple tags do not match

Laravel supports assigning multiple tags:

Cache::store('redis')
    ->tags(['products', 'featured'])
    ->put('featured:101', ['id' => 101], 600);

Retrieve the value using the same ordered tag list:

Cache::store('redis')
    ->tags(['products', 'featured'])
    ->get('featured:101');

Tag order and grouping matter for retrieving known keys.

Do not assume that a value stored under one tag combination is readable using another combination.

Flushing a shared tag may also affect multiple tagged groups.

Variation C: An expired entry remains in the index

A manually maintained index can outlive individual entries.

That is expected when the index has a longer lifetime.

The correct approach is to skip missing values and prune stale keys, as our all() method does.

Do not return null entries as valid products.

This example deliberately stores arrays, so null safely represents a missing cached value.

If your application intentionally caches null, use a sentinel value or a different missing-entry strategy.

Variation D: Someone suggests scanning Redis keys

You may find examples that use Redis KEYS commands to discover cached values.

Avoid using broad key scans on production Redis servers.

They can be expensive, and Laravel's tagged cache storage uses implementation-specific key naming and metadata.

Even Redis SCAN does not provide a portable Laravel cache-tag enumeration API.

An explicitly maintained index is easier to understand and maintain.

Variation E: One tagged product should not change another

Using Cache::tags(...)->flush() invalidates a tagged group.

If you only want to remove one known product, use:

Cache::store('redis')
    ->tags(['ht-demo-catalog'])
    ->forget('product:102');

However, if you use the registry service from this tutorial, prefer:

$catalog->remove(102);

That also updates the index.

Mixing direct cache writes with registry-managed writes can make the registry incomplete.

Variation F: The catalog becomes too large

Fetching thousands of independent entries on every request can create unnecessary cache operations and memory usage.

At that point, consider:

  • Returning paginated products from MySQL.
  • Caching individual products by known IDs.
  • Caching frequently requested result pages.
  • Keeping a bounded Redis set of identifiers.
  • Rebuilding cached collections from authoritative records.

Caching should reduce application workload, not create another complicated database layer.

Quick Verification: Confirm All Tagged Items Are Returned

Use one final Tinker test to verify the implementation.

php artisan tinker

Execute:

$catalog = app(\App\Support\CatalogTaggedCache::class);

$catalog->flush();

$catalog->put(101, [
    'id' => 101,
    'name' => 'Keyboard',
]);

$catalog->put(102, [
    'id' => 102,
    'name' => 'Mouse',
]);

count($catalog->all());

Expected result:

2

Remove one product:

$catalog->remove(101);

count($catalog->all());

Expected result:

1

Finally:

$catalog->flush();

$catalog->all();

Expected result:

[]

These are reproducible checks for an application with a working Redis connection. They are not a claim that the example has been executed on your particular server.

The key lesson is that cache tags handle grouping and invalidation, while retrieving every member requires an explicit collection key or application-managed key registry.

Next Steps

If your application only needs one cached collection, use the single-key approach from Step 3. If products require independent cache entries, adapt the registry service to your data model, add automated tests for concurrent writes and expiration, and ensure every write goes through the same service. For critical or very large datasets, keep MySQL as the source of truth and use Redis to accelerate known lookups rather than treating tagged cache storage as a searchable database.

More Laravel articles