cache/namespaced-cache
A PSR-6 decorator that scopes a shared cache pool to a single namespace, so clear() invalidates only that namespace. Includes tag support, generation-key safety, and | hierarchical key deletion.
cache/namespaced-cache is not a Laravel package. It has no service provider, no facade, no config/cache.php driver, and no Cache::namespace() method. It is a ~100-line PSR-6 decorator from the php-cache organisation. Anything you read claiming it drops into Cache::store('namespaced_redis') is wrong — plan your integration around PSR-6 or you will burn a day on it.
What it actually gives you:
$inner = new \Cache\Adapter\Redis\RedisCachePool($redisClient); // implements HierarchicalPoolInterface
$tenantPool = new \Cache\Adapter\Namespaced\NamespacedCachePool($inner, 'tenant:42');
$item = $tenantPool->getItem('profile.1'); // stored as tenant:42/profile.1
First steps:
composer require cache/namespaced-cache plus a concrete adapter (cache/redis-adapter, cache/array-adapter for tests).NamespacedCachePool itself — it is short and is the whole documentation. Then read PhpCache\Cache\HierarchicalPoolInterface and HierarchicalCachePoolTrait.$this->app->singleton(TenantCache::class, fn ($app) => new TenantCache($app->make('cache.psr6.inner')));
class TenantCache
{
public function __construct(private HierarchicalPoolInterface $inner) {}
public function for(string $namespace): NamespacedCachePool
{
return new NamespacedCachePool($this->inner, $namespace);
}
}
Good first use case: a secondary cache pool (separate Redis DB index or connection) used only for tenant-scoped data, so Laravel's sessions, queues, rate limiters and lock store keep using the native driver untouched.
Namespace as a factory, not a store. Resolve ->for($tenantId) at the edge (middleware, job handle(), controller), pass the pool down. Avoid injecting NamespacedCachePool as a singleton — the namespace is immutable once constructed.
You must write remember() yourself. PSR-6 has no such helper and Laravel's Cache::remember() won't touch your pool:
public function remember(NamespacedCachePool $pool, string $key, \DateInterval|int $ttl, callable $cb)
{
$item = $pool->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$value = $cb();
$pool->save($item->set($value)->expiresAfter($ttl));
return $value;
}
Keep stampede protection in Laravel. PSR-6 has no locks. Wrap the closure with Cache::lock("ns:{$ns}:{$key}") (Laravel's store) so concurrency control stays on a primitive that supports it.
Invalidate by versioning the namespace, not by flushing. Since clear() on the namespaced pool delegates to the underlying pool's clear() (i.e. it nukes every namespace), the multi-tenant pattern is tenant:42:v7. Bump the version number to invalidate and let TTLs evict the orphans. Store the current version in the database or a tiny Laravel cache key.
Coexistence layout. Typical production arrangement: Laravel redis store for sessions/queues/locks/rate limits, php-cache Redis adapter on a second connection (different DB index) for namespaced domain data. Same Redis, zero key collisions, no shared flush() risk.
Migration path. Read-through fallback while cutting over: try the namespaced pool, on miss check the legacy Laravel key, then backfill the namespaced entry. Delete the legacy key only once you're confident.
Testing. cache/array-adapter's ArrayCachePool implements the hierarchical interface — new pool per test, assert tenant:1 and tenant:2 don't see each other. This is the one place the package is genuinely pleasant.
Integration point if you must reuse Laravel's store. Write a small class implementing HierarchicalPoolInterface via HierarchicalCachePoolTrait that delegates getItem/save/deleteItem to an injected Illuminate\Contracts\Cache\Repository. It's ~40 lines and avoids running a second Redis client.
clear() is global. Never expose a "clear my tenant's cache" button mapped to NamespacedCachePool::clear(). Namespace versioning is the only safe invalidation.HierarchicalPoolInterface, not CacheItemPoolInterface. A Symfony Psr6Cache bridge or a generic PSR-6 pool will throw a TypeError. You cannot just wrap an arbitrary pool, and there's no generic "Laravel → PSR-6" shim that also satisfies the hierarchy interface.isHit(). PSR-6 returns a miss item with null, not false. $item->get() on a miss is silent null — a classic source of "cache is broken" bug reports.{}()/\@: in user keys, and the package joins namespace and key with a separator internally. Use dots or dashes in your own keys; passing user:1:profile risks an InvalidArgumentException.increment/decrement, no getMany guarantees, no tags. Counters, leaderboards, dedupe windows — keep those on the native Laravel store.DateTimeInterface or seconds/DateInterval. expiresAfter(3600) works, but expiresAt(new DateTimeImmutable('+1 hour')) is clearer and avoids "is this seconds or minutes" ambiguity in code review.saveDeferred()/commit() exist. If a teammate uses them, items don't persist until commit(). Rarely worth it outside bulk warmup.$item->getKey() (it includes the namespace) and verify in Redis with SCAN 0 MATCH "tenant:42/*" COUNT 1000 — never KEYS * on a shared instance.singleton and add them to Octane's flush list, or rebuild per request.[READ-ONLY] (split from the cache/cache monorepo), 14 stars, last tagged 1.x. But the decorator is ~100 lines — vendoring it or forking for a fix is a half-hour job, not a migration. Pin the minor version and don't build architecture that assumes upstream maintenance.Cache::tags() or plain key prefixes will serve you better there.How can I help you explore Laravel packages today?