Repository: laravel/framework
Pull request #55304: [12.x] Introduce memoized cache driver
Author: login: timacdonald
Created: 2025-04-06T23:42:39Z | Merged: 2025-04-11T19:55:19Z

We put things in the cache because the cache is fast; that doesn't mean hitting the cache is _free_.

This PR introduces a memoized cache driver. This new driver is a decorator around other cache drivers. It will remember values resolved from the cache and store them in memory for the remainder of the execution.

```php
Cache::get('foo');         // hits the cache
Cache::get('foo');         // hits the cache
Cache::get('foo');         // hits the cache

Cache::memo()->get('foo'); // hits the cache
Cache::memo()->get('foo'); // does not hit the cache
Cache::memo()->get('foo'); // does not hit the cache
```

The following illustrates the values returned:

```php
Cache::put('name', 'Taylor');
Cache::get('name');           // "Taylor"

Cache::put('name', 'Tim');
Cache::get('name');           // "Tim"

Cache::put('name', 'Taylor');
Cache::memo()->get('name');   // "Taylor"

Cache::put('name', 'Tim');
Cache::memo()->get('name');   // "Taylor"
```

The `memo` function accepts a driver name. This indicates the _real_ driver that the memo driver should decorate:

```php
// default driver...
Cache::memo()->get('name');

// redis driver...
Cache::memo('redis')->get('name');

// database driver...
Cache::memo('database')->get('name');
```

Each driver will get a unique memo decorator. This means values are not shared between different drivers:


```php
Cache::driver('redis')->put('name', 'Taylor in Redis');
Cache::driver('database')->put('name', 'Taylor in the database');

Cache::memo('redis')->get('name');    // "Taylor in Redis"
Cache::memo('database')->get('name'); // "Taylor in the database"
```

When you call a method on the memo driver that is intended to mutate the value in the cache, e.g., `put`, `remember`, etc., we forget the memoized value and then call the same method on the decorated driver:

```php
// assuming the default driver is 'redis'...

Cache::memo()->put('name', 'Taylor'); // writes to Redis
Cache::memo()->get('name');           // hits Redis
Cache::memo()->get('name');           // does not hit Redis
Cache::memo()->get('name');           // does not hit Redis

Cache::memo()->put('name', 'Taylor'); // forgets the memoized value for 'name' and writes to Redis
Cache::memo()->get('name');           // hits Redis
Cache::memo()->get('name');           // does not hit Redis
Cache::memo()->get('name');           // does not hit Redis
```

> [!NOTE]
> I had hopes and dreams that if you wrote to the cache via the memo driver we could actively remember the value. That way you don't need to retrieve it after a write. Unfortunately there were too many inconsistencies between drivers and different mutation methods for that to work. For example, when you call increment the value returned from the cache is an int. If you then retrieve that value via `get` it will be a string of the int.

The memo driver does not fire events. Events will be fired by the underlying driver if it is called.

When calling methods that mutate the cached value, the memo driver will always forget memoized value even if the call to the underlying driver fails.

One last bonus benefit of the memo driver is that is ensures consistent values from the cache throughout a request. Imagine you attempt to get the same key in two different places within a request and you get two different results because a different process updated the value or perhaps it _just_ TTLd out of the cache. That could cause unexpected runtime side effects on your application. Using `Cache::memo` ensures that you get the same result for the entire request or job lifecycle regardless of what happens in the cache while time progresses throughout the execution.

This idea has been bubbling away for a while and once we saw what was happening in some of our customers’ apps via Laravel Nightwatch, I knew we needed to make it happen. 


Diff:
diff --git a/src/Illuminate/Cache/CacheManager.php b/src/Illuminate/Cache/CacheManager.php
index c4973da748eb..0a0c2de5e171 100755
--- a/src/Illuminate/Cache/CacheManager.php
+++ b/src/Illuminate/Cache/CacheManager.php
@@ -71,6 +71,25 @@ public function driver($driver = null)
         return $this->store($driver);
     }
 
+    /**
+     * Get a memoized cache driver instance.
+     *
+     * @param  string|null  $driver
+     * @return \Illuminate\Contracts\Cache\Repository
+     */
+    public function memo($driver = null)
+    {
+        $driver = $driver ?: $this->getDefaultDriver();
+
+        if (! $this->app->bound($bindingKey = "cache.__memoized:{$driver}")) {
+            $this->app->scoped($bindingKey, fn () => $this->repository(
+                new MemoizedStore($driver, $this->store($driver)), ['events' => false]
+            ));
+        }
+
+        return $this->app->make($bindingKey);
+    }
+
     /**
      * Resolve the given store.
      *
diff --git a/src/Illuminate/Cache/MemoizedStore.php b/src/Illuminate/Cache/MemoizedStore.php
new file mode 100644
index 000000000000..9888810de6d7
--- /dev/null
+++ b/src/Illuminate/Cache/MemoizedStore.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Illuminate\Cache;
+
+use Illuminate\Contracts\Cache\Store;
+
+class MemoizedStore implements Store
+{
+    /**
+     * The memoized cache values.
+     *
+     * @var array<string, mixed>
+     */
+    protected $cache = [];
+
+    /**
+     * Create a new memoized cache instance.
+     *
+     * @param  string  $name
+     * @param  \Illuminate\Cache\Repository  $repository
+     */
+    public function __construct(
+        protected $name,
+        protected $repository,
+    ) {
+        //
+    }
+
+    /**
+     * Retrieve an item from the cache by key.
+     *
+     * @param  string  $key
+     * @return mixed
+     */
+    public function get($key)
+    {
+        $prefixedKey = $this->prefix($key);
+
+        if (array_key_exists($prefixedKey, $this->cache)) {
+            return $this->cache[$prefixedKey];
+        }
+
+        return $this->cache[$prefixedKey] = $this->repository->get($key);
+    }
+
+    /**
+     * Retrieve multiple items from the cache by key.
+     *
+     * Items not found in the cache will have a null value.
+     *
+     * @return array
+     */
+    public function many(array $keys)
+    {
+        [$memoized, $retrieved, $missing] = [[], [], []];
+
+        foreach ($keys as $key) {
+            $prefixedKey = $this->prefix($key);
+
+            if (array_key_exists($prefixedKey, $this->cache)) {
+                $memoized[$key] = $this->cache[$prefixedKey];
+            } else {
+                $missing[] = $key;
+            }
+        }
+
+        if (count($missing) > 0) {
+            $retrieved = tap($this->repository->many($missing), function ($values) {
+                $this->cache = [
+                    ...$this->cache,
+                    ...collect($values)->mapWithKeys(fn ($value, $key) => [
+                        $this->prefix($key) => $value,
+                    ]),
+                ];
+            });
+        }
+
+        $result = [
+            ...$memoized,
+            ...$retrieved,
+        ];
+
+        return array_replace(array_flip($keys), $result);
+    }
+
+    /**
+     * Store an item in the cache for a given number of seconds.
+     *
+     * @param  string  $key
+     * @param  mixed  $value
+     * @param  int  $seconds
+     * @return bool
+     */
+    public function put($key, $value, $seconds)
+    {
+        unset($this->cache[$this->prefix($key)]);
+
+        return $this->repository->put($key, $value, $seconds);
+    }
+
+    /**
+     * Store multiple items in the cache for a given number of seconds.
+     *
+     * @param  int  $seconds
+     * @return bool
+     */
+    public function putMany(array $values, $seconds)
+    {
+        foreach ($values as $key => $value) {
+            unset($this->cache[$this->prefix($key)]);
+        }
+
+        return $this->repository->putMany($values, $seconds);
+    }
+
+    /**
+     * Increment the value of an item in the cache.
+     *
+     * @param  string  $key
+     * @param  mixed  $value
+     * @return int|bool
+     */
+    public function increment($key, $value = 1)
+    {
+        unset($this->cache[$this->prefix($key)]);
+
+        return $this->repository->increment($key, $value);
+    }
+
+    /**
+     * Decrement the value of an item in the cache.
+     *
+     * @param  string  $key
+     * @param  mixed  $value
+     * @return int|bool
+     */
+    public function decrement($key, $value = 1)
+    {
+        unset($this->cache[$this->prefix($key)]);
+
+        return $this->repository->decrement($key, $value);
+    }
+
+    /**
+     * Store an item in the cache indefinitely.
+     *
+     * @param  string  $key
+     * @param  mixed  $value
+     * @return bool
+     */
+    public function forever($key, $value)
+    {
+        unset($this->cache[$this->prefix($key)]);
+
+        return $this->repository->forever($key, $value);
+    }
+
+    /**
+     * Remove an item from the cache.
+     *
+     * @param  string  $key
+     * @return bool
+     */
+    public function forget($key)
+    {
+        unset($this->cache[$this->prefix($key)]);
+
+        return $this->repository->forget($key);
+    }
+
+    /**
+     * Remove all items from the cache.
+     *
+     * @return bool
+     */
+    public function flush()
+    {
+        $this->cache = [];
+
+        return $this->repository->flush();
+    }
+
+    /**
+     * Get the cache key prefix.
+     *
+     * @return string
+     */
+    public function getPrefix()
+    {
+        return $this->repository->getPrefix();
+    }
+
+    /**
+     * Prefix the given key.
+     *
+     * @param  string  $key
+     * @return string
+     */
+    protected function prefix($key)
+    {
+        return $this->getPrefix().$key;
+    }
+}
diff --git a/src/Illuminate/Support/Facades/Cache.php b/src/Illuminate/Support/Facades/Cache.php
index 1463306365ca..593bd33ae596 100755
--- a/src/Illuminate/Support/Facades/Cache.php
+++ b/src/Illuminate/Support/Facades/Cache.php
@@ -4,6 +4,7 @@
 
 /**
  * @method static \Illuminate\Contracts\Cache\Repository store(string|null $name = null)
+ * @method static \Illuminate\Contracts\Cache\Repository memo(string|null $name = null)
  * @method static \Illuminate\Contracts\Cache\Repository driver(string|null $driver = null)
  * @method static \Illuminate\Contracts\Cache\Repository resolve(string $name)
  * @method static \Illuminate\Cache\Repository build(array $config)
diff --git a/tests/Integration/Cache/MemoizedStoreTest.php b/tests/Integration/Cache/MemoizedStoreTest.php
new file mode 100644
index 000000000000..e599f83225d0
--- /dev/null
+++ b/tests/Integration/Cache/MemoizedStoreTest.php
@@ -0,0 +1,382 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Cache;
+
+use Illuminate\Cache\Events\CacheEvent;
+use Illuminate\Cache\Events\CacheMissed;
+use Illuminate\Cache\Events\ForgettingKey;
+use Illuminate\Cache\Events\KeyForgotten;
+use Illuminate\Cache\Events\KeyWritten;
+use Illuminate\Cache\Events\RetrievingKey;
+use Illuminate\Cache\Events\RetrievingManyKeys;
+use Illuminate\Cache\Events\WritingKey;
+use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Redis;
+use Orchestra\Testbench\TestCase;
+
+class MemoizedStoreTest extends TestCase
+{
+    use InteractsWithRedis;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->setUpRedis();
+
+        Config::set('cache.default', 'redis');
+        Redis::flushAll();
+    }
+
+    protected function tearDown(): void
+    {
+        parent::tearDown();
+
+        $this->tearDownRedis();
+    }
+
+    public function test_it_can_memoize_when_retrieving_single_value()
+    {
+        Cache::put('name', 'Tim', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Tim', $live);
+        $this->assertSame('Tim', $memoized);
+
+        Cache::put('name', 'Taylor', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Taylor', $live);
+        $this->assertSame('Tim', $memoized);
+    }
+
+    public function test_null_values_are_memoized_when_retrieving_single_value()
+    {
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertNull($live);
+        $this->assertNull($memoized);
+
+        Cache::put('name', 'Taylor', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Taylor', $live);
+        $this->assertNull($memoized);
+    }
+
+    public function test_it_can_memoize_when_retrieving_mulitple_values()
+    {
+        Cache::put('name.0', 'Tim', 60);
+        Cache::put('name.1', 'Taylor', 60);
+
+        $live = Cache::getMultiple(['name.0', 'name.1']);
+        $memoized = Cache::memo()->getMultiple(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $memoized);
+
+        Cache::put('name.0', 'MacDonald', 60);
+        Cache::put('name.1', 'Otwell', 60);
+
+        $live = Cache::getMultiple(['name.0', 'name.1']);
+        $memoized = Cache::memo()->getMultiple(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Otwell'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $memoized);
+    }
+
+    public function test_null_values_are_memoized_when_retrieving_mulitple_values()
+    {
+        $live = Cache::getMultiple(['name.0', 'name.1']);
+        $memoized = Cache::memo()->getMultiple(['name.0', 'name.1']);
+        $this->assertSame($live, ['name.0' => null, 'name.1' => null]);
+        $this->assertSame($memoized, ['name.0' => null, 'name.1' => null]);
+
+        Cache::put('name.0', 'MacDonald', 60);
+        Cache::put('name.1', 'Otwell', 60);
+
+        $live = Cache::getMultiple(['name.0', 'name.1']);
+        $memoized = Cache::memo()->getMultiple(['name.0', 'name.1']);
+        $this->assertSame($live, ['name.0' => 'MacDonald', 'name.1' => 'Otwell']);
+        $this->assertSame($memoized, ['name.0' => null, 'name.1' => null]);
+    }
+
+    public function test_it_can_retrieve_already_memoized_and_not_yet_memoized_values_when_retrieving_mulitple_values()
+    {
+        Cache::put('name.0', 'Tim', 60);
+        Cache::put('name.1', 'Taylor', 60);
+
+        $live = Cache::get('name.0');
+        $memoized = Cache::memo()->get('name.0');
+        $this->assertSame('Tim', $live);
+        $this->assertSame('Tim', $memoized);
+
+        Cache::put('name.0', 'MacDonald', 60);
+        Cache::put('name.1', 'Otwell', 60);
+
+        $live = Cache::getMultiple(['name.0', 'name.1']);
+        $memoized = Cache::memo()->getMultiple(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Otwell'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Otwell'], $memoized);
+    }
+
+    public function test_put_forgets_memoized_value()
+    {
+        Cache::put(['name.0' => 'Tim', 'name.1' => 'Taylor'], 60);
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $memoized);
+
+        Cache::memo()->put('name.0', 'MacDonald');
+        Cache::memo()->put('name.1', 'Otwell');
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Otwell'], $live);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Otwell'], $memoized);
+    }
+
+    public function test_put_many_forgets_memoized_value()
+    {
+        Cache::memo()->put(['name.0' => 'Tim', 'name.1' => 'Taylor'], 60);
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $memoized);
+
+        Cache::memo()->put(['name.0' => 'MacDonald'], 60);
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Taylor'], $live);
+        $this->assertSame(['name.0' => 'MacDonald', 'name.1' => 'Taylor'], $memoized);
+    }
+
+    public function test_increment_forgets_memoized_value()
+    {
+        Cache::put('count', 1, 60);
+
+        $live = Cache::get('count');
+        $memoized = Cache::memo()->get('count');
+        $this->assertSame('1', $live);
+        $this->assertSame('1', $memoized);
+
+        Cache::memo()->increment('count');
+
+        $live = Cache::get('count');
+        $memoized = Cache::memo()->get('count');
+        $this->assertSame('2', $live);
+        $this->assertSame('2', $memoized);
+    }
+
+    public function test_decrement_forgets_memoized_value()
+    {
+        Cache::put('count', 1, 60);
+
+        $live = Cache::get('count');
+        $memoized = Cache::memo()->get('count');
+        $this->assertSame('1', $live);
+        $this->assertSame('1', $memoized);
+
+        Cache::memo()->decrement('count');
+
+        $live = Cache::get('count');
+        $memoized = Cache::memo()->get('count');
+        $this->assertSame('0', $live);
+        $this->assertSame('0', $memoized);
+    }
+
+    public function test_forever_forgets_memoized_value()
+    {
+        Cache::put('name', 'Tim', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Tim', $live);
+        $this->assertSame('Tim', $memoized);
+
+        Cache::memo()->forever('name', 'Taylor');
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Taylor', $live);
+        $this->assertSame('Taylor', $memoized);
+    }
+
+    public function test_forget_forgets_memoized_value()
+    {
+        Cache::put('name', 'Tim', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Tim', $live);
+        $this->assertSame('Tim', $memoized);
+
+        Cache::memo()->forget('name');
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertNull($live);
+        $this->assertNull($memoized);
+    }
+
+    public function test_flush_forgets_memoized_value()
+    {
+        Cache::put(['name.0' => 'Tim', 'name.1' => 'Taylor'], 60);
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $live);
+        $this->assertSame(['name.0' => 'Tim', 'name.1' => 'Taylor'], $memoized);
+
+        Cache::memo()->flush();
+
+        $live = Cache::get(['name.0', 'name.1']);
+        $memoized = Cache::memo()->get(['name.0', 'name.1']);
+        $this->assertSame(['name.0' => null, 'name.1' => null], $live);
+        $this->assertSame(['name.0' => null, 'name.1' => null], $memoized);
+    }
+
+    public function test_memoized_driver_uses_underlying_drivers_prefix()
+    {
+        $this->assertSame('laravel_cache_', Cache::memo()->getPrefix());
+
+        Cache::driver('redis')->setPrefix('foo');
+
+        $this->assertSame('foo', Cache::memo()->getPrefix());
+    }
+
+    public function test_memoized_keys_are_prefixed()
+    {
+        $redis = Cache::store('redis');
+
+        $redis->setPrefix('aaaa');
+        $redis->put('name', 'Tim', 60);
+        $redis->setPrefix('zzzz');
+        $redis->put('name', 'Taylor', 60);
+
+        $redis->setPrefix('aaaa');
+        $value = Cache::memo('redis')->get('name');
+        $this->assertSame('Tim', $value);
+
+        $redis->setPrefix('zzzz');
+        $value = Cache::memo('redis')->get('name');
+        $this->assertSame('Taylor', $value);
+    }
+
+    public function test_it_dispatches_decorated_driver_events_only()
+    {
+        $redis = Cache::driver('redis');
+        $events = [];
+        Event::listen('*', function ($type, $event) use (&$events) {
+            if ($event[0] instanceof CacheEvent) {
+                $events[] = $event[0];
+            }
+        });
+
+        Cache::memo('redis')->get('name');
+        $this->assertCount(2, $events);
+        $this->assertInstanceOf(RetrievingKey::class, $events[0]);
+        $this->assertSame('redis', $events[0]->storeName);
+        $this->assertSame('name', $events[0]->key);
+        $this->assertInstanceOf(CacheMissed::class, $events[1]);
+        $this->assertSame('redis', $events[1]->storeName);
+        $this->assertSame('name', $events[1]->key);
+        Cache::memo('redis')->get('name');
+        $this->assertCount(2, $events);
+
+        Cache::memo('redis')->many(['name']);
+        $this->assertCount(2, $events);
+
+        Cache::memo('redis')->many(['name.0', 'name.1']);
+        $this->assertCount(5, $events);
+        $this->assertInstanceOf(RetrievingManyKeys::class, $events[2]);
+        $this->assertSame('redis', $events[2]->storeName);
+        $this->assertSame(['name.0', 'name.1'], $events[2]->keys);
+        $this->assertInstanceOf(CacheMissed::class, $events[3]);
+        $this->assertSame('redis', $events[3]->storeName);
+        $this->assertSame('name.0', $events[3]->key);
+        $this->assertInstanceOf(CacheMissed::class, $events[4]);
+        $this->assertSame('redis', $events[4]->storeName);
+        $this->assertSame('name.1', $events[4]->key);
+
+        Cache::memo('redis')->many(['name.0', 'name.1']);
+        $this->assertCount(5, $events);
+
+        Cache::memo('redis')->put('name', 'Tim', 1);
+        $this->assertCount(7, $events);
+        $this->assertInstanceOf(WritingKey::class, $events[5]);
+        $this->assertSame('redis', $events[5]->storeName);
+        $this->assertSame('name', $events[5]->key);
+        $this->assertInstanceOf(KeyWritten::class, $events[6]);
+        $this->assertSame('redis', $events[6]->storeName);
+        $this->assertSame('name', $events[6]->key);
+
+        Cache::memo('redis')->putMany(['name.0' => 'Tim', 'name.1' => 'Taylor']);
+        $this->assertCount(11, $events);
+        $this->assertInstanceOf(WritingKey::class, $events[7]);
+        $this->assertSame('redis', $events[7]->storeName);
+        $this->assertSame('name.0', $events[7]->key);
+        $this->assertInstanceOf(KeyWritten::class, $events[8]);
+        $this->assertSame('redis', $events[8]->storeName);
+        $this->assertSame('name.0', $events[8]->key);
+        $this->assertInstanceOf(WritingKey::class, $events[9]);
+        $this->assertSame('redis', $events[9]->storeName);
+        $this->assertSame('name.1', $events[9]->key);
+        $this->assertInstanceOf(KeyWritten::class, $events[10]);
+        $this->assertSame('redis', $events[10]->storeName);
+        $this->assertSame('name.1', $events[10]->key);
+
+        Cache::memo('redis')->increment('count');
+        $this->assertCount(11, $events);
+
+        Cache::memo('redis')->decrement('count');
+        $this->assertCount(11, $events);
+
+        Cache::memo('redis')->forever('name', 'Taylor');
+        $this->assertCount(13, $events);
+        $this->assertInstanceOf(WritingKey::class, $events[11]);
+        $this->assertSame('redis', $events[11]->storeName);
+        $this->assertSame('name', $events[11]->key);
+        $this->assertInstanceOf(KeyWritten::class, $events[12]);
+        $this->assertSame('redis', $events[12]->storeName);
+        $this->assertSame('name', $events[12]->key);
+
+        Cache::memo('redis')->forget('name');
+        $this->assertCount(15, $events);
+        $this->assertInstanceOf(ForgettingKey::class, $events[13]);
+        $this->assertSame('redis', $events[13]->storeName);
+        $this->assertSame('name', $events[13]->key);
+        $this->assertInstanceOf(KeyForgotten::class, $events[14]);
+        $this->assertSame('redis', $events[14]->storeName);
+        $this->assertSame('name', $events[14]->key);
+
+        Cache::memo('redis')->flush();
+        $this->assertCount(15, $events);
+    }
+
+    public function test_it_resets_cache_store_with_scoped_instances()
+    {
+        Cache::put('name', 'Tim', 60);
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Tim', $live);
+        $this->assertSame('Tim', $memoized);
+
+        Cache::put('name', 'Taylor', 60);
+        $this->app->forgetScopedInstances();
+
+        $live = Cache::get('name');
+        $memoized = Cache::memo()->get('name');
+        $this->assertSame('Taylor', $live);
+        $this->assertSame('Taylor', $memoized);
+    }
+}


Reviews:
user: login: rodrigopedra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T03:42:09Z
user: login: timacdonald
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T04:10:51Z
user: login: timacdonald
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T07:03:04Z

Review comments:
diff_hunk: @@ -0,0 +1,205 @@
+<?php
+
+namespace Illuminate\Cache;
+
+use Illuminate\Contracts\Cache\Store;
+
+class MemoizedStore implements Store
+{
+    /**
+     * The memoized cache values.
+     *
+     * @var array<string, mixed>
+     */
+    protected $cache = [];
+
+    /**
+     * Create a new memoized cache instance.
+     *
+     * @param  string  $name
+     * @param  \Illuminate\Cache\Repository  $repository
+     */
+    public function __construct(
+        protected $name,
+        protected $repository,
+    ) {
+        //
+    }
+
+    /**
+     * Retrieve an item from the cache by key.
+     *
+     * @param  string  $key
+     * @return mixed
+     */
+    public function get($key)
+    {
+        $prefixedKey = $this->prefix($key);
+
+        if (array_key_exists($prefixedKey, $this->cache)) {
+            return $this->cache[$prefixedKey];
+        }
+
+        return $this->cache[$prefixedKey] = $this->repository->get($key);
path: src/Illuminate/Cache/MemoizedStore.php
user: login: rodrigopedra
body: Octane and long-running processes will benefit more from this, right?

Shouldn't we have some mechanism to limit the number of keys kept, to avoid the internal array growing indefinitely?


created_at: 2025-04-07T03:42:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,205 @@
+<?php
+
+namespace Illuminate\Cache;
+
+use Illuminate\Contracts\Cache\Store;
+
+class MemoizedStore implements Store
+{
+    /**
+     * The memoized cache values.
+     *
+     * @var array<string, mixed>
+     */
+    protected $cache = [];
+
+    /**
+     * Create a new memoized cache instance.
+     *
+     * @param  string  $name
+     * @param  \Illuminate\Cache\Repository  $repository
+     */
+    public function __construct(
+        protected $name,
+        protected $repository,
+    ) {
+        //
+    }
+
+    /**
+     * Retrieve an item from the cache by key.
+     *
+     * @param  string  $key
+     * @return mixed
+     */
+    public function get($key)
+    {
+        $prefixedKey = $this->prefix($key);
+
+        if (array_key_exists($prefixedKey, $this->cache)) {
+            return $this->cache[$prefixedKey];
+        }
+
+        return $this->cache[$prefixedKey] = $this->repository->get($key);
path: src/Illuminate/Cache/MemoizedStore.php
user: login: timacdonald
body: Each memo driver is bound as a scoped instance that is refreshed between octane requests and between queued jobs.

The intention is not to cache between executions but to memoize within the single execution.
created_at: 2025-04-07T04:10:51Z
author_association: MEMBER
diff_hunk: @@ -71,6 +71,25 @@ public function driver($driver = null)
         return $this->store($driver);
     }
 
+    /**
+     * Get a memoized cache driver instance.
+     *
+     * @param  string|null  $driver
+     * @return \Illuminate\Contracts\Cache\Repository
+     */
+    public function memo($driver = null)
+    {
+        $driver = $driver ?: $this->getDefaultDriver();
+
+        if (! $this->app->bound($bindingKey = "cache.__memoized:{$driver}")) {
+            $this->app->scoped($bindingKey, fn () => $this->repository(
+                new MemoizedStore($driver, $this->store($driver)), ['events' => false]
+            ));
+        }
path: src/Illuminate/Cache/CacheManager.php
user: login: timacdonald
body: We bind the memo drivers to the container as scoped instances so they are refreshed in Octane and the queue worker.
created_at: 2025-04-09T07:03:04Z
author_association: MEMBER

PR comments:
user: login: tpetry
created_at: 2025-04-09T09:50:08Z
body: Isn't this a more specific (limited?) implementation of a two-level cache? Like two drivers used one after nother? So an `array` driver first with something else last? A more generic multi-level implementation would work in the same. And even have more capabilities like: `array` > `redis` > `storage` to have multiple cache-levels with decreasing speed but much more storage.

So `Cache::mycache()->get()` would just an abstract store that calls its child stores one after nother for `get()` oeprations, while e.g. `set()` would be called on all of them.
author_association: CONTRIBUTOR
user: login: devajmeireles
created_at: 2025-04-09T19:43:35Z
body: > Isn't this a more specific (limited?) implementation of a two-level cache? Like two drivers used one after nother? So an `array` driver first with something else last? A more generic multi-level implementation would work in the same. And even have more capabilities like: `array` > `redis` > `storage` to have multiple cache-levels with decreasing speed but much more storage.
> 
> So `Cache::mycache()->get()` would just an abstract store that calls its child stores one after nother for `get()` oeprations, while e.g. `set()` would be called on all of them.

I think you're right. The difference is that he simplified it.
author_association: CONTRIBUTOR
user: login: devajmeireles
created_at: 2025-04-09T19:50:51Z
body: @timacdonald,

That's really cool. Is there a possibility of adding a `refresh` method? IMHO, that's the only missing part in this PR 😄 

```php
Cache::put('name', 'Taylor');
Cache::get('name');           // "Taylor"

Cache::put('name', 'Tim');
Cache::get('name');           // "Tim"

Cache::put('name', 'Taylor');
Cache::memo()->get('name');   // "Taylor"

Cache::put('name', 'Tim');
Cache::memo()->get('name');   // "Taylor"

Cache::memo()->refresh();

Cache::memo()->get('name');   // "Tim"
```
author_association: CONTRIBUTOR
user: login: devajmeireles
created_at: 2025-04-09T19:51:40Z
body: Any thoughts related to tests? 🤔 
author_association: CONTRIBUTOR
user: login: timacdonald
created_at: 2025-04-10T03:21:38Z
body: @tpetry, I can see a multi-level cache being a good addition to the framework.

That being said, I don't see this as the same thing. I would expect a multi-level cache to respect the TTL up and down the cache stack. If we had a cache stack implementation, e.g., `Cache::driver('stack:array,redis,s3')->get(...);`, I would expect the array driver to still respect the TTL and therefore the value could be TTLd within the current request and would have different return values within the one execution.

I feel that `Cache::memo` would be useful alongside a multi-level cache, e.g., `Cache::memo('stack:redis,s3')->get(...);`.

I'm not entirely sure how common it is for Laravel applications to have multiple cache levels. Is this a pattern you've commonly used or seen in Laravel applications?
author_association: MEMBER
user: login: timacdonald
created_at: 2025-04-10T03:25:34Z
body: @devajmeireles

> Any thoughts related to tests? 🤔

The memo driver will be flushed between tests as each instance is bound to the container. 

> Is there a possibility of adding a refresh method? IMHO, that's the only missing part in this PR 😄

Can you share some real-world code where doing that is something you would want? I can see it is interesting but I can't see where I would use it off the top of my head.
author_association: MEMBER
user: login: chu121su12
created_at: 2025-04-10T04:41:51Z
body: I am using user-land implementation of the 2-level cache in production now. Great feature indeed.

> note

Straying from decorator, why not add a `force<Mutate>()` methods to, well, force the mutation to the deocrated driver so a simple == check can be added in the regular mutation methods?

author_association: CONTRIBUTOR
user: login: chu121su12
created_at: 2025-04-10T05:08:52Z
body: Otherwise, considering repository implementation consider null as not cached and the `memo->get` is using `array_key_exists` why not add the == check selectively only on selective methods (I'm thinking only `put`, `putMany`, and `forever`)? Forcing would then require call to `memo->forget`.
author_association: CONTRIBUTOR
user: login: devajmeireles
created_at: 2025-04-10T18:52:07Z
body: > @devajmeireles
> 
> > Any thoughts related to tests? 🤔
> 
> The memo driver will be flushed between tests as each instance is bound to the container.
> 
> > Is there a possibility of adding a refresh method? IMHO, that's the only missing part in this PR 😄
> 
> Can you share some real-world code where doing that is something you would want? I can see it is interesting but I can't see where I would use it off the top of my head.

Considering the primary objective of this PR, `refresh` would be helpful to update the state saved in the `memo` within the same life cycle, because there are several cases where we can update values ​​saved in the cache to retrieve them later.

A real example that I can take from one of my software is an auditing log concept, which as actions occur in the life cycle, I store them and gradually update them so that only at the `terminate` of a Middleware do I persist them using `Concurrency`.
author_association: CONTRIBUTOR
user: login: mahmoudmohamedramadan
created_at: 2025-04-10T19:15:13Z
body: > @tpetry, I can see a multi-level cache being a good addition to the framework.
> 
> That being said, I don't see this as the same thing. I would expect a multi-level cache to respect the TTL up and down the cache stack. If we had a cache stack implementation, e.g., `Cache::driver('stack:array,redis,s3')->get(...);`, I would expect the array driver to still respect the TTL and therefore the value could be TTLd within the current request and would have different return values within the one execution.
> 
> I feel that `Cache::memo` would be useful alongside a multi-level cache, e.g., `Cache::memo('stack:redis,s3')->get(...);`.
> 
> I'm not entirely sure how common it is for Laravel applications to have multiple cache levels. Is this a pattern you've commonly used or seen in Laravel applications?

I completely agree with @tpetry. This seems like an extra layer of caching on top of the existing one, which is uncommon and may lead developers to question why there's a cache for something Laravel already handles out of the box. It also introduces additional overhead—more logic and processing time just to check if a cached value exists. On top of that, it could encourage even more unnecessary layers in the future.
author_association: CONTRIBUTOR
user: login: Treggats
created_at: 2025-04-16T08:33:23Z
body: @timacdonald can I just say that I love how elaborate your PR descriptions are. 🔥 
author_association: CONTRIBUTOR
user: login: timacdonald
created_at: 2025-04-16T11:29:14Z
body: Thank you, @Treggats 
author_association: MEMBER
user: login: decadence
created_at: 2025-04-16T11:45:53Z
body: Does this driver proxy all calls to the underlying driver? Including if I call the `remember` method (which I don't see in `MemoizedStore` class)?
author_association: CONTRIBUTOR
user: login: rodrigopedra
created_at: 2025-04-16T11:55:55Z
body: @decadence

The `remember()` method is part of the `Illuminate\Cache\Repository`, which builds up a `Illuminate\Contracts\Cache\Store` implementation, which the `Illuminate\Cache\MemoizedStore` is an implementation.

So at the end when calling `Cache::memo()->remember(...)` the calls the `Repository` makes to the `Store` will be delegated to the underlying `Store`, the `MemoizedStore` decorates upon.
author_association: CONTRIBUTOR
user: login: decadence
created_at: 2025-04-16T13:06:13Z
body: @rodrigopedra Thanks!
author_association: CONTRIBUTOR
user: login: joostdebruijn
created_at: 2025-04-17T09:12:33Z
body: Thanks @timacdonald! Nice feature. I was wondering why the `MemoizedStore` doesn't extend the `TaggableStore`? I'm using tagged Redis caches frequently in one of my applications and I was wrapping some calls to the cache in a call to the array cache driver to achieve something similar to this. However, as the `MemoizedStore` doesn't support tags I cannot replace that pattern unfortunately.

For the sake of testing I just patched the `MemoizedStore` to extend the `TaggableStore` and that _seems_ to work on the surface, but I'm not sure if it is that easy. 😉 
author_association: CONTRIBUTOR
user: login: inmanturbo
created_at: 2025-04-18T22:33:04Z
body: oOh wow @timacdonald. This is very nice!
author_association: CONTRIBUTOR
user: login: royduin
created_at: 2025-04-25T06:34:18Z
body: If anyone needs it; a multi level cache implementation: https://github.com/rapidez/laravel-multi-cache
author_association: CONTRIBUTOR