webmozart/key-value-store
Webmozart KeyValueStore offers a simple PHP abstraction for key-value storage with pluggable backends, letting applications switch between local and remote stores without changing code. Useful for caching, configuration, and persistent maps.
Install and first look
composer require webmozart/key-value-store
Before writing any code, read Webmozart\KeyValueStore\Api\KeyValueStore. The entire contract is six methods:
interface KeyValueStore
{
public function exists($key);
public function get($key); // throws NoSuchKeyException
public function set($key, $value);
public function setIfNotExists($key, $value); // returns bool
public function remove($key);
public function removeAll();
}
Everything else in the package is a backend implementation of that interface (ArrayStore, FilesystemStore, JsonFileStore, RedisStore, MemcachedStore, DoctrineCacheStore, RiakStore, NullStore).
Minimal first use case: a persistent runtime flag
use Webmozart\KeyValueStore\Api\NoSuchKeyException;
use Webmozart\KeyValueStore\ArrayStore;
$store = new ArrayStore();
$store->set('billing.enabled', true);
try {
$enabled = $store->get('billing.enabled');
} catch (NoSuchKeyException $e) {
$enabled = false;
}
Wire it into Laravel once
In a service provider, bind the interface rather than the concrete class so tests and environments can swap backends:
// app/Providers/KeyValueServiceProvider.php
use Webmozart\KeyValueStore\Api\KeyValueStore;
use Webmozart\KeyValueStore\ArrayStore;
use Webmozart\KeyValueStore\RedisStore;
use Predis\Client;
public function register(): void
{
$this->app->singleton(KeyValueStore::class, function ($app) {
if ($app->environment('testing')) {
return new ArrayStore();
}
return new RedisStore(new Client([
'host' => config('database.redis.default.host'),
'port' => config('database.redis.default.port'),
'database' => 3, // do not share with cache/queues
]));
});
}
Inject KeyValueStore into the few services that need durable key-value semantics. That is the whole onboarding: one interface, one binding, one try/catch around get().
Contract-first injection, backend chosen at the edge. Never new RedisStore(...) in domain code. Type-hint KeyValueStore; let the provider decide whether that is ArrayStore, JsonFileStore, or RedisStore. This is the package's main selling point and the only reason to prefer it over Cache:: calls scattered through the codebase.
Environment matrix. A pattern that works well with this package:
| Environment | Store | Why |
|---|---|---|
testing |
ArrayStore |
Fast, isolated, no external services |
local |
JsonFileStore |
Inspectable file, no Redis required |
production |
RedisStore |
Shared across app servers |
Because the interface has no TTL or connection semantics, the swap is genuinely transparent — unlike swapping Laravel cache drivers where tags() or increment() may disappear.
Atomic "first writer wins" for locks and idempotency. setIfNotExists() maps to SETNX/add in the backing stores. This is the day-to-day feature Laravel's Cache::add() also gives you, but here it is explicit in the contract:
$acquired = $store->setIfNotExists("lock:report:{$id}", getmypid());
if (! $acquired) {
return; // another worker owns it
}
try {
$this->generateReport($id);
} finally {
$store->remove("lock:report:{$id}");
}
Treat it as a best-effort mutex, not a distributed lock with expiry — see gotchas.
Manual TTL envelope. Since the API has no expiration, store an envelope and check it yourself:
$store->set('otp:'.$userId, [
'value' => $code,
'expires_at' => now()->addMinutes(10)->getTimestamp(),
]);
$record = $store->get('otp:'.$userId); // wrap in try/catch
if ($record['expires_at'] < time()) {
$store->remove('otp:'.$userId);
// treat as expired
}
Only do this for low-volume data. If you find yourself reimplementing expiry, use Laravel's cache instead.
Namespacing by hand. Most backends are flat. Prefix keys at the repository layer:
final class FeatureFlags
{
public function __construct(private KeyValueStore $store) {}
public function enabled(string $flag): bool
{
try {
return (bool) $this->store->get("app:flags:{$flag}");
} catch (NoSuchKeyException) {
return false;
}
}
}
A single FeatureFlags/SettingsRepository wrapper keeps key formatting, exception handling, and serialization assumptions in one place — and makes it trivial to migrate to Cache::rememberForever() later.
Store snapshots and typed payloads deliberately. Values are serialized by the backend (see gotchas), so arrays and small DTOs round-trip. This is useful for "last processed cursor" or "previous sync payload" records. Keep payloads small and version them (['v' => 2, ...]) because changing a DTO's properties will break unserialize() on old data.
Testing pattern. Bind ArrayStore in the container and assert against it directly; use NullStore when you want to prove code paths tolerate a store that never has anything. Seed ArrayStore with its constructor array where supported:
$this->app->instance(KeyValueStore::class, new ArrayStore([
'app:flags:new-checkout' => true,
]));
Integrate at seams, not everywhere. Good fits: feature flags, small settings, cursors/watermarks, idempotency markers, dedupe keys for webhooks. Poor fits: session data, cache of expensive queries, queues, anything needing counters or scans. Those belong to Laravel's cache/Redis abstractions.
Missing keys throw, they don't return null. get() raises NoSuchKeyException. Every read is a try/catch or an exists() check — and exists() followed by get() is a race. Prefer catching the exception. Wrap reads in a repository method so the try/catch doesn't leak into controllers.
No TTL, no increment, no scan/keys. The interface is intentionally minimal. If you need expiry, counters, tags, or key listing, this package is the wrong tool; use Illuminate\Support\Facades\Cache or Redis directly. The envelope pattern above is a workaround, not a feature.
Not PSR-16 (psr/simple-cache). You cannot drop it in wherever a CacheInterface is expected, and Laravel's cache repository does not implement this interface. Expect to write an adapter in either direction.
Values are serialize()d by most backends. Consequences:
json_decode yourself.Key normalization is a real source of collisions. Keys are converted to strings; 1 and "1" are the same key. Long or unusual keys may be rejected or behave differently per backend. Namespace with a stable string prefix and avoid user-supplied keys without sanitizing.
Redis backend uses its own client (Predis), not Laravel's connection. You get a second connection pool, separate auth/DB settings, and none of Laravel's key prefixing. Pick a dedicated database number (e.g. database => 3), or accept silent key collisions with cache/session/queue keys. Also check cluster/Sentinel compatibility of whatever Predis version you install.
setIfNotExists() is atomic on Redis/Memcached but is not a safe distributed lock. There is no expiry, so a crashed worker leaves a permanent lock. Add a timestamp in the value and clean up stale locks in a scheduled command, or use Cache::lock() instead.
FilesystemStore / JsonFileStore caveats. Watch directory permissions, concurrent writers, and container read-only filesystems. removeAll() on a filesystem store deletes everything under its directory — never point it at a shared path. Atomic rename behavior matters if multiple PHP-FPM workers write.
Memcached key limits. 250-byte keys, no spaces/control characters. Validate or hash long keys before they reach the store, or you'll get silent misses/misses-as-errors depending on the client.
Package age is the biggest day-to-day risk. Last release was 2016. Expect friction on PHP 8.1+ (deprecations in dependencies, Serializable deprecations), and Composer conflicts with modern Laravel/Symfony versions, often via the pinned webmozart/assert. Practical options, in order:
app/Support, orKeyValueStore interface of your own backed by Cache/Redis — you keep the swappable-backend design without the 2016 dependency graph.Extension points. Implementing KeyValueStore yourself is trivial (six methods) if you need a database-backed or S3-backed store. Decorators (prefixing, logging, read-through caching) are easy to add around the interface — often more valuable than adding another backend.
Debugging checklist.
$key plus the resolved prefix before catching.ArrayStore is actually bound — a leaked singleton from a previous test is a common cause.setIfNotExists return semantics and key validation rules per implementation.redis-cli --scan --pattern 'your:prefix:*' and unserialize() locally rather than guessing from raw bytes.How can I help you explore Laravel packages today?