cache/prefixed-cache
Decorates any PSR-6 or PSR-16 cache so every key automatically carries a fixed prefix. PrefixedCachePool::create() also supports native tags, and prefixes are safely encoded before joining to keys.
What it is: a ~50-line decorator that implements Psr\Cache\CacheItemPoolInterface, wraps any other PSR-6 pool, and concatenates a string onto every key before delegating. That's the entire feature set. There is no Laravel service provider, no facade, no config file, and no integration with Illuminate\Contracts\Cache\Repository.
Install
composer require cache/prefixed-cache
# you also need an actual PSR-6 pool — Laravel's cache stores are NOT PSR-6
composer require cache/redis-cache # or symfony/cache, cache/array-adapter, ...
Minimal use (verbatim from the README, note the missing separator):
use Cache\Prefixed\PrefixedCachePool;
$anyPSR6Pool = new RedisCachePool($client);
$prefixedPool = new PrefixedCachePool($anyPSR6Pool, 'acme'); // key "user" becomes "acmeuser"
Do this first — a 60-second smoke test with the array adapter. Do not wire it into Redis until you've seen the key names and the clear() behaviour with your own eyes:
$inner = new Cache\Adapter\PHPArray\ArrayCachePool();
$prefixed = new Cache\Prefixed\PrefixedCachePool($inner, 'shop.v1.');
$prefixed->save($prefixed->getItem('products')->set('x'));
var_dump(array_keys($inner->getValues())); // ["shop.v1.products"] — confirms concatenation
$prefixed->clear();
var_dump(count($inner->getValues())); // 0 — confirms clear() nukes the WHOLE inner pool
Where to look first
src/PrefixedCachePool.php in the repo — read the whole file, it's shorter than this section. Everything you need to know is there.Changelog.md — the release notes on Packagist just say "See change log", so that file is the only real changelog.Best first use case in a Laravel app: prefix-versioned cache rollover, not multi-tenancy. Include a version segment in the prefix ("acme.{$env}.v{$cacheVersion}.") so a deploy that bumps CACHE_VERSION instantly invalidates everything without ever calling clear(). That gives you a safe, zero-risk way to try the package on a real store, and it sidesteps the package's single biggest footgun (see below).
This is the only pattern where the package is unambiguously the right tool. Some library in your dependency tree typehints Psr\Cache\CacheItemPoolInterface (HTTP cache middleware, serializer caches, schema/metadata caches). You hand it a prefixed pool and it can never collide with your application keys:
$this->app->singleton(SomeLibrary::class, function ($app) {
$pool = new PrefixedCachePool(
$app->make(RedisCachePool::class),
'lib.somelibrary.'
);
return new SomeLibrary($pool); // library writes to lib.somelibrary.*, nothing else
});
Keep your own reference to the base pool in the container if you ever need to bypass the prefix — there is no documented accessor to reach the inner pool from the wrapper.
// config/cache.php
'pool_prefix' => sprintf(
'%s.%s.v%s.',
Str::slug(config('app.name')),
config('app.env'),
config('app.cache_version', '1') // bump on release / cache-breaking change
),
$pool = new PrefixedCachePool($redisPool, config('cache.pool_prefix'));
Old keys age out via TTL; no flush, no downtime, trivial rollback (revert the env var). Pair this with SCAN MATCH 'acme.production.v*' in your ops runbook for cleanup.
Use a scoped binding so the prefix is resolved per request, not frozen at boot:
// AppServiceProvider::register()
$this->app->scoped(Psr\Cache\CacheItemPoolInterface::class, function ($app) {
$tenant = tenant('id') ?? 'shared';
return new PrefixedCachePool(
$app->make(RedisCachePool::class),
"tenant.{$tenant}.api."
);
});
scoped() is important — under Octane a singleton() would capture tenant #1's prefix and serve it to every subsequent tenant in that worker. That is a data-leak-shaped bug, not a performance bug.
For a finer granularity, use contextual binding so only one consumer gets the tenant-scoped pool:
$this->app->when(ReportingService::class)
->needs(Psr\Cache\CacheItemPoolInterface::class)
->give(fn () => new PrefixedCachePool($base, 'tenant.'.tenant('id').'.reports.'));
Cheap and genuinely useful in a suite that shares one Redis instance:
protected function setUp(): void
{
parent::setUp();
$this->app->instance(Psr\Cache\CacheItemPoolInterface::class,
new PrefixedCachePool(
new ArrayCachePool(), // per-test, no Redis at all
'test.'.Str::random(8).'.'
)
);
}
Swap in a real Redis pool with a random prefix when you deliberately want cache hits across a single test.
PrefixedCachePool is a plain decorator, so it stacks. Order determines the final key:
$pool = new PrefixedCachePool(
new PrefixedCachePool($redisPool, 'app.'), // inner prefix
'acme.' // outer prefix
);
// final key: acme.app.user_123
Because save()/saveDeferred()/commit() pass straight through, always call commit() on the outermost wrapper if you defer anything.
The decorator does no normalisation. Put every prefix construction in one place so it is greppable and reviewable:
final class CacheNamespace
{
public static function prefix(string $module, ?string $tenant = null): string
{
return Str::slug(implode('.', array_filter([
config('app.name'), config('app.env'), $tenant, $module,
'v'.config('app.cache_version', '1'),
]))).'.';
}
}
Then new PrefixedCachePool($redisPool, CacheNamespace::prefix('billing', tenant('id'))).
Cache facadeYou can't. Cache::extend() callbacks must return a Illuminate\Contracts\Cache\Repository, and Laravel's Store implementations are not PSR-6 pools — there is no getStore() you can hand to this class and have it work. Your options, in order of preference:
prefix option in config/cache.php (Redis/Memcached/database stores all support it). This covers almost every "shared Redis across apps" scenario with zero dependencies.Cache::store('redis')->tags(['tenant:5'])->put(...)) if what you actually want is scoped invalidation — the tag namespace already isolates keys, and tags()->flush() only clears that tag, which this package cannot do.Illuminate\Contracts\Cache\Store adapter that translates get/put/forget/flush onto a PSR-6 pool. Only worth it if a PSR-6 pool is forced on you from elsewhere.Wrap one non-critical store first (sessions or a reporting cache), verify hit-rate and key names in Redis, then expand. Never wrap a pool in one service provider while other code writes to the same pool unprefixed — you'll end up with two key formats for the same logical data and an unflushable mess.
The package adds no separator. The README's own example (new PrefixedCachePool($pool, 'acme')) produces acmeuser, not acme:user. Always supply your own trailing delimiter in the prefix string. Forgetting it is the number-one way to get prefix collisions (tenant1 vs tenant12 → tenant1user vs tenant12user is fine, but acme vs acmeshop overlaps).
clear() flushes the entire underlying pool. The decorator has no way to enumerate prefixed keys through PSR-6, so it delegates straight through. On a shared Redis database this wipes every other app's cache. If you have any code path that can reach clear()/flush(), use a dedicated Redis database index per namespace group, or adopt the version-in-prefix pattern instead of flushing. This is the single most important thing to verify in your smoke test.
Delimiter choice is constrained by PSR-6. Keys must not contain the reserved characters { } ( ) / \ @ :. Symfony's cache adapters (CacheItem::validateKey) will throw InvalidArgumentException on a colon; php-cache's own adapters are more permissive, which makes this bug environment-specific and easy to miss. Use . or _ as the delimiter if there's any chance a Symfony pool ends up underneath.
Budget your key length. Prefix + key must fit the backend limit — 250 bytes for Memcached, 64 characters in several PSR-6 implementations. A UUID tenant id plus a module plus a version plus a hashed key blows past that in a hurry, and truncation means silent collisions. Hash long components (substr(sha1($tenantUuid), 0, 8)) before they enter the prefix.
getKey() returns the prefixed key. The CacheItemInterface you get back from getItem() is the inner pool's item; $item->getKey() gives you acme.user_123, not user_123. Anything that logs, tags, or branches on the item key must expect the prefix. Similarly, verify in a test whether getItems(['a','b']) is keyed by your original keys or by the prefixed ones before you index into its result by $keys.
Double-prefixing is silent. Reusing a key you got from getKey() and passing it back into getItem() produces acme.acme.user_123. No exception, just a permanent cache miss and a Redis instance full of garbage. Keep a rule: application code only ever sees unprefixed keys; the wrapper is the only place a prefix exists.
Wrong prefix = 100% miss rate, never an error. hasItem() just returns false. If you see hit rate drop to zero after a deploy, diff the prefix string before you debug anything else. Log the resolved prefix exactly once at boot (and in php artisan about-style diagnostics) so it's visible.
Check the psr/cache constraint before you commit. This package is a read-only split of the php-cache/cache monorepo and has historically targeted the psr/cache ^1.0 interface, whose methods have no return types. A pool/interface pinned to ^3.0 (Symfony 6+, many modern adapters) has typed signatures that a v1 implementor can't satisfy — you'll find out at composer update or with a fatal signature error. composer why psr/cache is the ten-second check.
No escape hatch, no config, no extension points. There's no getInnerPool(), no event, no middleware, no way to override key generation. If you need scoped clear(), key mapping back to originals, a clearPrefix() method, or an accessor to the inner pool, don't fork the package — copy its ~50 lines into your own App\Cache\PrefixedPool and own it. That is genuinely cheaper than a dependency with 2 stars, 0 dependents, and a READ-ONLY banner.
Treat it as a reference implementation, not a dependency. The decorator pattern it demonstrates is the valuable part. Laravel's own Illuminate\Cache\DecoratedCache and the native prefix config solve adjacent problems with people actively maintaining them. Adopt this only when a PSR-6 pool is non-negotiable and Laravel's native prefix/tags can't reach it.
Debugging recipe: reproduce with cache/array-adapter (its getValues() shows you the literal keys), confirm the exact concatenation, then move to Redis and inspect with SCAN 0 MATCH 'yourprefix.*' COUNT 100 — never KEYS * on a shared instance. Log $prefixed->getItem($k)->getKey() once in a test to pin down what your consumers will actually see.
How can I help you explore Laravel packages today?