spiral/roadrunner-kv
PSR-16 cache bridge for the RoadRunner KV plugin. Install via Composer, configure one or more kv drivers in RoadRunner, then use Spiral\RoadRunner\KeyValue\Factory over RPC to select a named store and set, get, or delete cached values. Ideal for RoadRunner-powered PHP apps.
Install the package via Composer (note the renamed package — see Gotchas):
composer require roadrunner/kv
The old name spiral/roadrunner-kv is superseded by roadrunner/kv as of v4.5.0. Do not require both.
Enable and configure the RoadRunner KV plugin in .rr.yaml. This package now ships RoadRunner v3 support, and the plugin's config shape differs between RR v2 and v3. For RR v3:
version: '3'
rpc:
listen: tcp://127.0.0.1:6001
kv:
memory: # name becomes the pool selector
driver: memory
config: {}
redis:
driver: redis
config:
addrs:
- localhost:6379
If you are still on RR v2, keep the older kv.pools.<name> / kv.default layout. After installing, confirm the exact shape against your rr binary version and the vendor/roadrunner/kv/ source — v4.5.0 is the release where the two RR generations overlap.
Build the client through the factory, not by instantiating the interface (it is an interface, not a constructor):
use Spiral\Goridge\RPC\RPC;
use Spiral\RoadRunner\KeyValue\Factory;
$factory = new Factory(RPC::create('tcp://127.0.0.1:6001'));
$kv = $factory->select('memory'); // pool name from .rr.yaml
$kv->set('user:123', json_encode(['name' => 'Alice']));
$data = json_decode($kv->get('user:123'), true);
First use case: Cache expensive computation (an upstream API response, a report query) inside the worker lifecycle for sub-millisecond reads with a TTL — the highest-value, lowest-risk entry point.
In-worker caching with TTL
$cacheKey = 'expensive:' . md5($request->getQueryString());
$cached = $kv->get($cacheKey);
if ($cached !== null) {
return json_decode($cached, true);
}
$result = $this->doHeavyComputation($request);
$kv->set($cacheKey, json_encode($result), 30); // TTL seconds, driver-dependent
return $result;
Cross-worker state sharing Use a Redis-backed pool when more than one worker must agree on a value (feature flags, cheap rate-limit counters). Be aware the client exposes simple get/set/delete semantics rather than atomic increments — for counters where races matter, use Redis directly through a dedicated client, or serialize access.
PSR-16 adapter If you want Laravel-familiar cache semantics, use the package's PSR-16 implementation instead of hand-rolling:
$storage = $factory->select('redis')->asSimpleCache(); // PSR-16 SimpleCache
This gives you get/set/has/delete with the same pooling behaviour and is a good seam for swapping in ArrayAdapter in tests.
Laravel service container binding
// app/Providers/RoadRunnerServiceProvider.php
public function register(): void
{
$this->app->singleton(Factory::class, fn () => new Factory(
RPC::create(config('roadrunner.rpc'))
));
$this->app->bind('kv', fn ($app) => $app->make(Factory::class)
->select(config('roadrunner.kv.pool', 'memory')));
}
Bind the factory as the singleton and the pool as a scoped/aliased binding — this lets you switch pools per call site without extra connections.
Guard for non-RoadRunner contexts
The client talks to the rpc plugin over a socket. Under php artisan serve, Octane-less queue workers, or a plain PHPUnit run there is no RoadRunner process, so select() will fail at first I/O. Wrap the binding:
if (! config('roadrunner.enabled')) {
$this->app->bind('kv', fn () => new \Illuminate\Cache\Repository(
new \Illuminate\Cache\ArrayStore()
));
}
Typed error handling
Catch the package's exceptions rather than \Exception, and log driver failures distinctly from cache misses — a miss is normal, a driver failure is an incident.
⚠️ Breaking: package rename (v4.5.0)
spiral/roadrunner-kv → roadrunner/kv. Update composer.json, any composer require lines in CI, Dockerfiles, and docs. Since the old name may still resolve to an older tag, a stale require silently pins you to pre-v4.5 behaviour. Run composer why roadrunner/kv after upgrading to confirm you have one package, not two.
⚠️ Breaking: RoadRunner v3 support means the KV config shape changed
The kv plugin config is not forward-compatible between RR v2 and v3 (pool map vs. pools/default keys). Upgrading the PHP package without upgrading rr — or vice versa — is the most likely failure mode. Keep .rr.yaml and the rr binary version pinned together, and note that v4.5.0 is a transitional release carrying both generations.
⚠️ Verify the namespace on upgrade
The rename is at the Composer level; the PHP namespace in the 4.x line may still be Spiral\RoadRunner\KeyValue\*. Check the actual files after composer update before mass-renaming use statements, and pin the major so a future namespace move doesn't surprise you.
⚠️ No built-in serialization
The client handles scalars and strings only. json_encode/json_decode yourself — and remember it, because storing an array will not throw, it will store something surprising.
⚠️ TTL is driver-dependent
The memory driver ignores or truncates TTLs depending on config; redis honours them. Never rely on expiry for correctness — only for cost control. Expire defensively with a versioned key or a timestamp inside the payload if staleness matters.
🔧 Pool name must match exactly
$factory->select('memory') against a config key redis is a runtime RPC error, not a boot error. Resolve the pool name from config, never hardcode it.
🔧 RPC endpoint must match too
RPC::create('tcp://127.0.0.1:6001') has to mirror rpc.listen. In containerised deployments the socket is commonly a Unix socket (unix:///var/run/rr.sock) — mismatch here produces confusing "connection refused" errors that look like cache failures.
🧪 Testing
In tests, bind the interface to the PSR-16 ArrayAdapter-style in-memory implementation (or the package's simple-cache adapter over the memory pool) so no RoadRunner process is needed. Reserve a real rr serve smoke test for one integration test that exercises TTL and pool selection.
🛠 Extension point
remember() does not exist. Add it as a thin wrapper and put your serialization policy in one place:
class CachedService
{
public function __construct(private KeyValueInterface $kv) {}
public function getUser(int $id): array
{
$key = "user:$id";
$raw = $this->kv->get($key);
if ($raw !== null) {
return json_decode($raw, true);
}
$user = $this->fetchFromDb($id);
$this->kv->set($key, json_encode($user), 60);
return $user;
}
}
📊 Profiling and debugging
RoadRunner ships its own logging/metrics for plugins; enable the rpc and kv log channels at the RR level (v3 uses logs: with per-plugin level). If reads are slow, measure RPC round-trip time at the socket boundary first — a busy loop of get() calls inside one request costs more than one batched fetch, so design keys to allow reading a single blob per request.
🔄 Health checks
Ping the RPC endpoint during boot (a trivial kv call such as a has() on a sentinel key) and fail fast with a clear message, rather than letting the first user request discover a dead socket.
How can I help you explore Laravel packages today?