beste/in-memory-cache
A PSR-6 in-memory cache for PHP that works as a sensible default implementation and in tests. It supports PSR-20 clocks, including frozen clocks, so you can test time-based expiry without a real cache server. Simple, fast, and dependency-free. Install with composer require beste/in-memory-cache.
Requirements first: beste/in-memory-cache 1.5.x requires PHP 8.3+. On Laravel 10 (PHP 8.1/8.2) Composer will refuse to install it, so pin the older minor:
# PHP 8.3+ (Laravel 11/12)
composer require beste/in-memory-cache --dev
# PHP 8.1 / 8.2 (Laravel 10)
composer require "beste/in-memory-cache:1.4.*" --dev
Note the --dev. In a Laravel app this is almost always a test utility, not a production cache driver — see Gotchas for why.
Where to look first: there is only one class that matters, Beste\Cache\InMemoryCache, implementing Psr\Cache\CacheItemPoolInterface. Its constructor is __construct(?ClockInterface $clock = null). That's the entire public surface. Skim the README, then the changelog (1.2.0 relaxed key rules, 1.3.1 made the clock nullable) and you're done — there is no config file, no service provider, no facade.
First use case: a service in your app that type-hints PSR-6 rather than Laravel's Repository contract.
namespace App\Services;
use Psr\Cache\CacheItemPoolInterface;
final class ExchangeRateProvider
{
public function __construct(private CacheItemPoolInterface $cache) {}
public function rateFor(string $currency): float
{
$item = $this->cache->getItem('fx.'.$currency);
if (! $item->isHit()) {
$item->set($this->fetchFromApi($currency))->expiresAfter(300);
$this->cache->save($item);
}
return $item->get();
}
}
In the test, give it a real pool instead of a mock:
$provider = new ExchangeRateProvider(new InMemoryCache());
$this->assertSame(1.08, $provider->rateFor('EUR'));
$this->assertSame(1.08, $provider->rateFor('EUR')); // served from cache, no second HTTP call
That's the 60-second version: replace $this->createMock(CacheItemPoolInterface::class) with a working implementation you can assert against after the fact.
The cleanest day-to-day use is: your production code depends on Psr\Cache\CacheItemPoolInterface, Laravel binds a real pool in production, and your test suite swaps in the in-memory one.
// AppServiceProvider::register()
$this->app->singleton(CacheItemPoolInterface::class, function ($app) {
return $app->make(SomeRealPsr6Pool::class); // e.g. Symfony Cache, Redis PSR-6 adapter
});
// tests/TestCase.php
protected function setUp(): void
{
parent::setUp();
$this->app->instance(CacheItemPoolInterface::class, new InMemoryCache());
}
Because it's singleton-scoped to the app instance, every test case gets a fresh, empty pool — no Redis::flushdb(), no flaky network.
Cache facade via Laravel's PSR-6 adapterIf the code under test goes through Cache::get() / Cache::remember() rather than a PSR-6 type-hint, wrap the pool with Illuminate\Cache\Psr6Cache (which extends Psr16Cache and implements Illuminate\Contracts\Cache\Repository):
// AppServiceProvider::boot() — register only in non-production
use Beste\Cache\InMemoryCache;
use Illuminate\Cache\Psr6Cache;
use Illuminate\Support\Facades\Cache;
Cache::extend('in-memory', fn () => new Psr6Cache(new InMemoryCache()));
// config/cache.php
'in-memory' => ['driver' => 'in-memory'],
// phpunit.xml: <env name="CACHE_STORE" value="in-memory"/>
Cache::store('in-memory')->remember('stats', 60, fn () => $this->collect());
This gives facade-level realism (remember, put, forget, flush) with zero I/O. Check that Illuminate\Cache\Psr6Cache exists in your Laravel version; if it doesn't, a 30-line decorator over the pool is trivial.
travel() / Carbon::setTestNow() will not move this cache — it reads a PSR-20 clock. Inject Beste\Clock\FrozenClock:
use Beste\Cache\InMemoryCache;
use Beste\Clock\FrozenClock;
$clock = FrozenClock::fromUTC();
$cache = new InMemoryCache($clock);
$item = $cache->getItem('token');
$item->set('abc')->expiresAfter(new DateInterval('PT1H'));
$cache->save($item);
$clock->setTo($clock->now()->add(new DateInterval('PT59M')));
$this->assertTrue($cache->getItem('token')->isHit());
$clock->setTo($clock->now()->add(new DateInterval('PT2M')));
$this->assertFalse($cache->getItem('token')->isHit());
This is the feature you can't easily fake with Cache::fake() and the main reason to reach for this package over a plain array.
Rather than asserting that save() was called, seed the pool and assert behaviour:
$cache = new InMemoryCache();
$cache->save($cache->getItem('exchange.eur')->set(1.08));
$sut = new PriceCalculator($cache);
$this->assertSame(10.80, $sut->priceInEur(10.0));
Under PHP-FPM you get a fresh pool per request, which is usually what you want. Under Octane the pool survives between requests and tenants — bind it scoped() and clear it in terminate() if you use it at all there.
It is not a Laravel cache Store. Cache::fake(), Cache::spy(), and cache()->driver() know nothing about it. Everything routes through Psr6Cache or a PSR-6 type-hint.
The "production fallback cache" pitch doesn't hold for Laravel. Under PHP-FPM the pool is destroyed at the end of every request, so it caches nothing across requests — a fallback that only lives for the current request is a placebo, not resilience. Under Octane it persists, which means unbounded memory growth and cross-tenant leakage. Treat it as a test double and per-request scratch cache, and keep the real Redis/Memcached fallback story for a real store.
PSR-6 reserved characters will bite you. { } ( ) / \ @ : are forbidden in cache keys. Laravel-style keys such as user:42, api/rates/usd, or @handle throw InvalidArgumentException. Normalise before caching:
$key = str_replace(['{','}','(',')','/', '\\', '@', ':'], '.', $key);
(Dashes and >64-char keys are fine — that restriction was lifted in 1.2.0.)
The README clock example is buggy. It builds $clock and then calls new InMemoryCache() without passing it, so nothing is frozen. Pass new InMemoryCache($clock).
Objects are stored by reference, not serialized. Mutating an object after save() mutates the cached entry — unlike Redis. Never hold a reference and assume snapshot semantics.
It can hide serialization bugs. Because there's no serialize()/unserialize() boundary, closures, resources, or non-serializable objects will "work" in tests and blow up against Redis. Keep at least one integration test against the real store for anything non-scalar.
A miss is indistinguishable from a cached null without isHit(). Always check isHit(), never get() === null.
Grab items fresh after time travel. $item is bound to the moment it was fetched; re-call getItem() after advancing the clock rather than reusing the old item.
No eviction, no max size, no TTL sweep. Expired entries are only removed lazily. In queue workers or long Pest/PHPUnit runs, call $cache->clear() in tearDown(); there's no key-enumeration API, so track the keys you seed in a helper if you need to assert on pool contents.
Feature gaps through the Laravel adapter: tags, Cache::lock(), rate limiting, and WithoutOverlapping won't work — locks throw and increment/decrement are non-atomic read-modify-write. flush() maps to clear().
Pin 1.4.* on PHP < 8.3, or Composer will silently fail to resolve and you'll spend ten minutes blaming a different package.
Extension point: wrap the pool in a CacheItemPoolInterface decorator for hit/miss counters or call logs rather than subclassing; it keeps the boundary explicit and works identically against the real pool in production.
Risk profile is low but narrow: 26 stars, 0 dependents, same author as beste/clock, MIT. Tiny, stable, well-tested surface — but don't build product features on it, and don't expect community answers when something looks odd.
How can I help you explore Laravel packages today?