cache/doctrine-adapter
This package adapts legacy Doctrine\Common\Cache implementations to PSR-6 and PSR-16, letting existing Doctrine caches work with modern cache pools. Version 3 changes the tagged-item format, so drain workers and clear the cache before upgrading or rolling back.
What this package actually is: a ~80-line adapter that lets a Doctrine\Common\Cache\Cache instance (Memcached, Redis, APCu, Filesystem, Array) be consumed through Psr\Cache\CacheItemPoolInterface. It is not a Laravel cache driver, not a taggable pool, and it does not replace Illuminate\Cache. Treat it as a boundary object between a legacy Doctrine-configured cache and PSR-6 consumers.
Step 1 — Install with the Doctrine version pinned to 1.x.
composer require cache/doctrine-adapter doctrine/cache:^1.13
Doctrine\Common\Cache\* drivers were removed in doctrine/cache 2.0. If anything in your tree pulls doctrine/cache:^2, the adapter will not install and the driver classes won't exist. Check first:
composer why doctrine/cache
php -r "var_dump(class_exists(Doctrine\Common\Cache\RedisCache::class));"
Step 2 — Look at two files, in this order:
vendor/cache/doctrine-adapter/src/DoctrineCachePool.php — you can read it end-to-end in a minute. Everything surprising about this package lives there.vendor/cache/adapter-common/src/CachePool.php + CacheItem.php — the real implementation (PSR-6 mechanics, key validation, expiry mapping). The Doctrine adapter is a thin save/fetch/contains/delete/clear shim over it.Step 3 — First use case, in php artisan tinker:
use Cache\Adapter\Doctrine\DoctrineCachePool;
use Doctrine\Common\Cache\ArrayCache; // swap for RedisCache/ApcuCache later
$pool = new DoctrineCachePool(new ArrayCache());
$item = $pool->getItem('reports.daily.2024-06-01');
if (! $item->isHit()) {
$item->set(['rows' => 128])->expiresAfter(300);
$pool->save($item);
}
dump($item->isHit(), $item->get());
If that round-trips, the only remaining work is deciding where the $pool comes from in your app.
Step 4 — Decide your integration shape. In Laravel you have two options, and they are very different:
getItem()/save(). No Laravel cache semantics, no events, no Cache:: facade.Illuminate\Contracts\Cache\Store so Cache::store('doctrine')->remember(...) works. More code, but you keep Laravel ergonomics (see below).// AppServiceProvider::register()
$this->app->singleton(DoctrineCachePool::class, function () {
$redis = new \Redis();
$redis->connect(config('database.redis.default.host'), 6379);
$doctrine = new \Doctrine\Common\Cache\RedisCache();
$doctrine->setRedis($redis);
$doctrine->setNamespace('acme:psr6'); // keep it away from ORM query cache
return new DoctrineCachePool($doctrine);
});
The value proposition is sharing infrastructure that a legacy/Doctrine bundle already created: same Memcached pool, same Redis connection, same namespace conventions, same ops runbook. Wrap it and hand it to PSR-6 consumers.
$memcached = new \Memcached();
$memcached->addServer('localhost', 11211);
$doctrineCache = new \Doctrine\Common\Cache\MemcachedCache();
$doctrineCache->setMemcached($memcached);
$doctrineCache->setNamespace('reports');
$pool = new DoctrineCachePool($doctrineCache);
setMemcached() / setRedis() are mandatory — the driver lazily uses whatever you injected, and forgetting them produces a fatal on first fetch() rather than a sane exception.
If you want Cache::store('doctrine'), do not add a 'driver' => 'doctrine' entry to config/cache.php — Laravel has no such driver and will throw InvalidArgumentException: Driver [doctrine] is not supported. Register it with Cache::extend() in AppServiceProvider::boot():
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Cache\Store;
use Psr\Cache\CacheItemPoolInterface;
class DoctrinePoolStore implements Store
{
public function __construct(
private CacheItemPoolInterface $pool,
private string $prefix = ''
) {}
public function get($key)
{
$item = $this->pool->getItem($this->prefix.$key);
return $item->isHit() ? $item->get() : null;
}
public function many(array $keys)
{
$items = $this->pool->getItems(
array_map(fn ($k) => $this->prefix.$k, $keys)
);
$values = [];
foreach ($keys as $key) {
$item = $items[$this->prefix.$key];
$values[$key] = $item->isHit() ? $item->get() : null;
}
return $values;
}
public function put($key, $value, $seconds)
{
$item = $this->pool->getItem($this->prefix.$key)
->set($value)
->expiresAfter($seconds ?: null); // 0 => forever in Laravel, null in PSR-6
return $this->pool->save($item);
}
public function putMany(array $values, $seconds) { /* loop put() */ }
public function increment($key, $value = 1) { /* get() + put(), not atomic */ }
public function decrement($key, $value = 1) { /* get() - put(), not atomic */ }
public function forever($key, $value) { return $this->put($key, $value, null); }
public function forget($key) { return $this->pool->deleteItem($this->prefix.$key); }
public function flush() { return $this->pool->clear(); }
public function getPrefix() { return $this->prefix; }
}
// boot()
Cache::extend('doctrine', function ($app) {
$store = new DoctrinePoolStore(
$app->make(DoctrineCachePool::class),
$app['config']['cache.prefix'].':'
);
return Cache::repository($store);
});
Then config/cache.php: 'doctrine' => ['driver' => 'doctrine'] works, and Cache::store('doctrine')->remember(...) behaves normally. Note that recent Laravel versions add touch() to the Store contract — implement the full interface and you won't break on upgrade.
Do not set CACHE_DRIVER=doctrine globally. Use it explicitly for one concern (report aggregates, third-party API payloads, expensive taxonomy trees) and leave sessions, queues, rate limiters, and Cache::lock() on Redis/database. This adapter has no atomic increments and no locks.
ArrayCache as a test double$this->app->instance(
DoctrineCachePool::class,
new DoctrineCachePool(new \Doctrine\Common\Cache\ArrayCache())
);
Clean, per-process, no mocking required. It behaves like Laravel's array store — including not sharing state across PHP processes, so integration tests that shell out won't see it.
DoctrineCachePool is not taggable, and Doctrine\Common\Cache\MemcachedCache has no tagging either. The php-cache ecosystem's answer is a decorator:
use Cache\Taggable\TaggablePSR6PoolAdapter;
$taggable = TaggablePSR6PoolAdapter::makeTaggable(
new DoctrineCachePool($doctrineCache)
);
Cache\Taggable implements tags by writing a tag-index item per tag and invalidating through it — read the source before trusting it in production, because invalidation is O(tags) reads/writes and there is no atomicity guarantee across the index and the entries. If tag invalidation is a hard requirement, Laravel's Redis store (TaggableStore) is the safer path.
PhpFileCache-style drivers let you generate a cache during deploy and read it at runtime:
// php artisan cache:warm
$pool = new DoctrineCachePool(new \Doctrine\Common\Cache\FileCache(storage_path('framework/doctrine')));
$pool->save($pool->getItem('taxonomy.tree')->set($tree)->expiresAfter(null));
Only do this with a writable, persistent path — see gotchas below.
Laravel's Cache::tags() will explode on this pool. TagSet builds keys containing :, and PSR-6 forbids { } ( ) / \ @ : in keys. DoctrineCachePool::getItem() throws InvalidArgumentException (from Cache\Adapter\Common\CacheKey::validateKey()). Same for any Eloquent/URL-derived key like user:1 or api/v2/products. Sanitize before it reaches the pool:
$key = strtr($raw, ['{' => '.', '}' => '.', '(' => '.', ')' => '.',
'/' => '.', '\\' => '.', '@' => '.', ':' => '.']);
Better: hash the key (sha1($raw)) — it sidesteps PSR-6 restrictions, the Memcached 250-byte limit, and the stricter charset that the underlying driver may silently enforce (MemcachedCache is far pickier than PSR-6; it doesn't error, it mangles).
The pool stores CacheItem objects in the backend, not raw values. That has three consequences:
FileCache, RedisCache, MemcachedCache, ArrayCache are fine. PhpFileCache is not — it uses var_export(), which requires __set_state() on CacheItem and will produce broken output. Avoid it.cache/adapter-common or your own value classes can leave unserializable entries behind. Flush the namespace on deploy, or version the key prefix (reports:v2:).redis-cli will show a serialized PHP object. Don't expect human-readable values, and don't let another service read the same namespace.flush() is a namespace-wide napalm. If your pool shares a namespace with Doctrine ORM's query/metadata cache, php artisan cache:clear (routed through DoctrinePoolStore::flush()) nukes ORM caches too. Always setNamespace('acme:psr6') on the Doctrine provider and keep it distinct.
No observability for free. Bypassing Illuminate\Cache\Repository means no CacheHit/CacheMissed/KeyWritten events, so Telescope and Debugbar show nothing, and cache:clear/cache:forget only work if you built the Store wrapper. If you use the raw pool, add your own instrumentation or you will be debugging blind.
Increment/decrement are read-modify-write. PSR-6 has no atomic counters. Rate limiters, view counters, and Cache::increment() semantics will race under load. Keep those on Redis.
Driver-specific landmines:
RedisCache needs phpredis (\Redis), not Predis — use PredisCache for Predis.ApcuCache only shares state within a single PHP-FPM pool on a single host; multi-pod deployments get N independent caches.FileCache/FileCache::setDirectory() — the default is sys_get_temp_dir(), which in containers is pod-ephemeral, so caches evaporate on restart and differ between replicas.Expiry mapping is relative seconds, and Doctrine's 0 means forever while PSR-6's null means forever. The adapter handles the mapping, but your DoctrinePoolStore::put() must translate Laravel's $seconds === 0 to expiresAfter(null) or you'll write items that instantly expire.
Deferred saves need a commit(). saveDeferred() queues into the pool and does nothing until commit(). There's no Laravel hook that calls it — if you use deferred writes, commit explicitly at end of request or don't use them.
Maintenance reality check. This is a [READ-ONLY] split repo (issues/PRs live in php-cache/cache), 13 stars, last release years old, and its sole job is to bridge a deprecating API (doctrine/cache 1.x, removed in ORM 3). The whole implementation is small enough to vendor into app/Support/DoctrineCachePool.php in an afternoon, which removes the abandoned-dependency risk entirely. Do that if this is going anywhere long-lived.
Alternatives worth pricing out before committing:
symfony/cache ships Symfony\Component\Cache\Adapter\DoctrineAdapter, a maintained and better-tested equivalent — usually the better choice if you already have Symfony components via Laravel's internals.cache/doctrine-bridge (PSR-6 → Doctrine), is what you want if the goal is to feed Laravel/Redis caches into Doctrine ORM's query cache — a much more common Laravel need than this direction.cache/redis-adapter / cache/filesystem-adapter skip Doctrine entirely.Testing/forking tip: the php-cache org publishes cache/integration-tests with a CachePoolTest trait that runs the full PSR-6 compliance suite. Use it if you fork or build your own Store wrapper — it catches the key-validation and expiry edge cases you'd otherwise ship.
How can I help you explore Laravel packages today?