The Cache Hit You Still Paid For: Laravel's Memo Driver and Tagged Memoization

Laravel's memo cache driver collapses repeated Redis reads inside one request. How Cache::memo() works, tagged memoization in 13.33, and Cache::touch() for TTL.

Steven Richardson
Steven Richardson
· 10 min read

Open the Telescope cache watcher on a normal page load and count how many times one key gets read. On a settings-heavy request I profiled last month the answer was eleven: a view composer, an authorisation policy, a middleware, and three Blade partials all asked Redis for settings:billing. Every one was a hit. Every one was also a full round trip — roughly 0.4ms each, so about four milliseconds of pure waste on every single request, invisible precisely because nothing was failing.

Laravel's memo cache driver fixes that in one line. Laravel 13.33 made it work with tags too, which is the part that was actually stopping me using it in anger.

Measure the repeated cache reads#

Before changing anything, get the number. The memoized repository is deliberately built with cache events disabled, so anything that listens to CacheHit and CacheMissed — Telescope, Debugbar, Pulse — reports real store round trips and nothing else. That makes the before/after comparison honest rather than flattering.

Start with Telescope's cache watcher, or drop a counter into a service provider if you want the number in a test:

use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Support\Facades\Event;

// In a local service provider or a test setup hook.
Event::listen([CacheHit::class, CacheMissed::class], function ($event) {
    logger()->debug('cache read', ['key' => $event->key]);
});

Then confirm it against Redis itself, because framework-level counters can lie about pipelining:

# In a second terminal. Never leave MONITOR running on production.
redis-cli MONITOR | grep 'settings:billing'
1790671823.412094 [0 127.0.0.1:54120] "GET" "laravel_cache:settings:billing"
1790671823.413771 [0 127.0.0.1:54120] "GET" "laravel_cache:settings:billing"
1790671823.415402 [0 127.0.0.1:54120] "GET" "laravel_cache:settings:billing"
... eleven of these for one request

If you have not yet got a watcher wired up, the comparison of Telescope, Debugbar and Pulse covers which one to reach for.

Wrap the store with Cache::memo()#

Cache::memo() returns a repository whose store is a MemoizedStore decorating your real store. The first read for a key goes through to Redis and the resolved value is kept in a plain PHP array; every later read for that key in the same request returns from the array. Call it with no argument for the default store, or name one explicitly.

use Illuminate\Support\Facades\Cache;

// Hits Redis, memoizes the result...
$value = Cache::memo()->get('settings:billing');

// Returns the memoized value, no round trip...
$value = Cache::memo()->get('settings:billing');

// A specific store gets its own memo layer...
$value = Cache::memo('redis')->get('settings:billing');

The useful shape for real code is a service that every layer resolves, rather than trying to thread the value through as a parameter — that is what fails when the call sites are a middleware, a policy and a partial that share no call stack:

final class Settings
{
    public function get(string $key): mixed
    {
        return Cache::memo()->remember(
            "settings:{$key}",
            now()->addHour(),
            fn () => SettingRecord::query()->whereKey($key)->value('value'),
        );
    }
}

Two details worth knowing, because they surprise people. Misses are memoized as well as hits, so a key that does not exist is asked for once, not eleven times. And writes do not update the memo copy — put, forget, increment and friends delete the memoized entry and then delegate to the underlying store:

Cache::memo()->put('name', 'Taylor'); // Writes through, drops the memo entry...
Cache::memo()->get('name');           // Reads Redis, memoizes...
Cache::memo()->get('name');           // Memoized...

Cache::memo()->put('name', 'Tim');    // Drops the memo entry again...
Cache::memo()->get('name');           // Reads Redis again...

That is why remember() on a cold key costs two round trips rather than one: internally it is a get followed by a put, and the put clears what the get memoized. Warm keys behave exactly as you would hope. Everything else about driver selection, stampedes and invalidation is in the complete guide to Laravel caching — this article is deliberately narrow.

Memoize a tagged cache#

Until Laravel 13.33 this was the blocker. Cache::memo() had no tags() method, so anyone caching per-user permissions or per-tenant config was hand-rolling a second array store on top of the tagged cache. PR #61593 landed a MemoizedTaggedCache, and the hand-rolled version can go.

Here is the pattern that used to be necessary:

// Before 13.33 — layering an array store to memoize a tagged read.
return Cache::store('array')->tags('permissions')->rememberForever(
    "permissions:{$user->id}",
    fn () => Cache::tags(["user:{$user->id}", 'permissions'])->remember(
        "permissions:{$user->id}",
        3600,
        fn () => $this->loadPermissionsFromDatabase($user),
    ),
);

And the replacement:

return Cache::memo()->tags(["user:{$user->id}", 'permissions'])->remember(
    "permissions:{$user->id}",
    3600,
    fn () => $this->loadPermissionsFromDatabase($user),
);

The important behaviour is what happens on a flush. MemoizedTaggedCache::flush() clears the memoized copies held for every tag set on that memo store before flushing the underlying tagged cache, so code that invalidates and re-reads inside one request gets fresh data rather than the value it was trying to throw away:

$perms = Cache::memo()->tags(['permissions'])->get("permissions:{$user->id}");

$user->roles()->detach($role);

Cache::memo()->tags(['permissions'])->flush();

// Re-reads from the store — not the pre-detach memoized copy.
$perms = Cache::memo()->tags(['permissions'])->get("permissions:{$user->id}");

Tags need a store that supports them. file, database, dynamodb and storage do not, and calling tags() on a memo layer over one of those throws a BadMethodCallException at runtime rather than quietly degrading. If you are moving off Redis, migrating cache, queues and sessions to Valkey keeps tag support intact.

Replace the get-then-put TTL dance with Cache::touch()#

Sliding expiry is usually written as a read, then a write with a fresh TTL. That is two round trips, a full deserialise and re-serialise of the payload, and a window in which a concurrent write gets clobbered. Cache::touch() extends the TTL in place instead.

// Two commands, re-serialises the whole payload, races with concurrent writes.
$session = Cache::get("rate:{$key}");
Cache::put("rate:{$key}", $session, 3600);

// One command, touches nothing but the expiry.
Cache::touch("rate:{$key}", 3600);

On Redis that compiles to a single EXPIRE. It returns true when the key existed and its expiry moved, false when the key was already gone — which makes it a neat "extend if still alive" primitive:

if (! Cache::touch("session:{$id}", 1800)) {
    // Key expired between requests; rebuild it.
    Cache::put("session:{$id}", $this->rebuild($id), 1800);
}

You can pass an absolute time rather than a number of seconds, and every first-party store implements it — Redis, Memcached, file, database, DynamoDB and array:

Cache::touch('key', now()->addHours(2));

Passing a zero or negative TTL forgets the key rather than touching it, which is consistent with put but catches people out.

Check the memo scope under Octane and queue workers#

This is the question everyone asks and almost everyone answers wrongly. The memo store is not a singleton — CacheManager::memo() registers it as a scoped container binding (cache.__memoized:{driver}), and scoped bindings are exactly the ones Laravel drops at request and job boundaries.

Under Octane, FlushTemporaryContainerInstances runs on OperationTerminated and calls forgetScopedInstances(), so the memo array is discarded between requests. On the queue side, the worker's reset-scope callback in QueueServiceProvider calls forgetScopedInstances() before every job. Both boundaries are covered by default, on RoadRunner, FrankenPHP and Swoole alike.

Verify it in your own setup rather than trusting me, because a custom octane.listeners array that no longer includes the default set will break the guarantee:

// routes/web.php — temporary check, delete afterwards.
Route::get('/memo-leak-check', function () {
    Cache::memo()->put('probe', request()->ip(), 60);

    return Cache::memo()->get('probe');
});

Hit it from two different machines in quick succession. If the second response echoes the first machine's IP, your scoped instances are not being flushed and you have a data-leak-shaped bug rather than a performance one. The deployment details are in the FrankenPHP production guide and the RoadRunner worker tuning guide.

The uncovered case is a long-running Artisan command. Nothing resets the scope inside a single php artisan process, so a command looping over a million records with Cache::memo() inside the loop will grow the memo array until the process dies. Call app()->forgetScopedInstances() at the end of each chunk, or do not memoize there.

Verify the round-trip count in a test#

Measuring once is a data point; asserting it is a guarantee. Because the memoized repository emits no events of its own, counting CacheHit and CacheMissed gives you the true number of store reads, and you can pin it so a regression fails CI.

use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Support\Facades\Event;

it('reads the billing settings key at most once per request', function () {
    $reads = 0;

    Event::listen([CacheHit::class, CacheMissed::class], function ($event) use (&$reads) {
        if (str_contains($event->key, 'settings:billing')) {
            $reads++;
        }
    });

    $this->actingAs(User::factory()->create())
        ->get('/dashboard')
        ->assertOk();

    expect($reads)->toBeLessThanOrEqual(1);
});

Re-run the Redis MONITOR trace afterwards and the eleven GET commands should be one.

Gotchas and Edge Cases#

Tag order matters twice over. MemoizedStore::tags() caches its MemoizedTaggedCache instances under serialize($names), so tags(['a', 'b']) and tags(['b', 'a']) get separate memo arrays — and separate tag namespaces in the store underneath. Pick an order and stick to it.

Mixing access styles defeats the whole thing. Cache::memo() and Cache::memo('redis') are different scoped bindings with different memo arrays, and Cache::get() bypasses the memo layer entirely. Worse, a write through the plain facade leaves the memoized copy untouched and stale for the rest of the request:

Cache::memo()->get('counter');  // Memoized as 5.
Cache::increment('counter');    // Bypasses the memo layer entirely.
Cache::memo()->get('counter');  // Still 5.

Cache::memo()->forget('counter'); // The escape hatch.

Route every access to a memoized key through the same memo instance, or do not memoize it.

A tagged flush clears the tagged memo arrays, not the untagged one. If you read a key through Cache::memo()->get() and later flush a tag that happens to cover it, the untagged memoized copy survives. Keep tagged and untagged access to the same key apart.

Memoization is the anaesthetic, not the cure. Eleven reads of one key in one request is a structural problem — the value should have been resolved once into a scoped singleton. Reach for memo when the call sites genuinely span layers you do not control; reach for a singleton when they are yours. In Livewire specifically, computed properties with persist are usually the better answer.

Wrapping Up#

Add memo() to the two or three keys that every request touches — settings, feature flags, permissions — then pin the read count in a test so the win does not quietly erode. Swap any read-then-write TTL extension for Cache::touch() while you are in there; it is strictly fewer commands for identical behaviour.

If four milliseconds of Redis chatter was not actually your bottleneck, go and find out what is: profiling a slow Laravel endpoint with SPX and Blackfire will tell you in an afternoon, and the complete guide to Laravel caching covers the invalidation strategy that makes any of this safe.

FAQ#

What is the memo cache driver in Laravel?

The memo driver is a decorator that wraps another cache store and keeps resolved values in a PHP array for the duration of the current request or queued job. The first read of a key goes to the real store; subsequent reads of the same key in the same execution come from memory. It is not a cache store you configure in config/cache.php — you reach it through Cache::memo().

How is Cache::memo() different from a normal cache store?

A normal store talks to Redis, Memcached or disk on every call, so ten reads of one key are ten round trips. Cache::memo() collapses those to one by holding the resolved value in process. It never persists anything itself and it never replaces your real store — writes pass straight through to the underlying store, and the memoized copy is deleted rather than updated when you write.

Does the memo driver work with cache tags?

Yes, from Laravel 13.33 onwards. Cache::memo()->tags([...]) returns a MemoizedTaggedCache that memoizes tagged reads and, crucially, clears the memoized copies when you flush a tag. Before 13.33 the memo driver had no tags() method at all. Tags still require a store that supports them, so Redis or Memcached rather than file or database.

What does Cache::touch() do?

Cache::touch('key', 3600) extends the time-to-live of an existing cache entry without reading or rewriting the value. On Redis it issues a single EXPIRE command. It returns true if the key existed and its expiry moved, and false if the key was not there. Passing a zero or negative TTL forgets the key instead.

Is the memo cache driver safe under Laravel Octane?

Yes, by default. CacheManager::memo() registers the memoized repository as a scoped container binding, and Octane's FlushTemporaryContainerInstances listener calls forgetScopedInstances() when each operation terminates, so nothing carries between requests. The same applies to queue workers, which reset scoped instances before each job. If you have replaced Octane's default listener array with a custom one, verify the behaviour yourself before trusting it.

How do I stop repeated Redis reads in a single Laravel request?

Route the reads through Cache::memo() so only the first one reaches Redis, and make sure every call site uses the same memo instance — mixing Cache::get() and Cache::memo()->get() for the same key defeats it. For a value you own end to end, a scoped singleton resolved once is cleaner still. Confirm the result with Telescope's cache watcher or a redis-cli MONITOR trace rather than assuming.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.