roadrunner/kv
RoadRunner KV is a PHP client for RoadRunner's key-value storage plugin, offering a simple API for get, set, delete, and batch operations. Use it with memory, BoltDB, Redis, or Memcached backends for fast caching and persistent data in RoadRunner-powered apps.
Prerequisites: RoadRunner (the Go application server) running as the PHP process manager, with the kv plugin enabled. This package is the PHP-side SDK that talks to the plugin over Goridge RPC — it does not embed storage itself, so nothing works without a running RoadRunner instance.
Install:
composer require roadrunner-php/kv
Enable the plugin in .rr.yaml and pick at least one driver:
rpc:
listen: tcp://127.0.0.1:6001
kv:
storages:
cache:
driver: memory # fast, per-server, non-persistent
shared:
driver: redis
addrs: ["127.0.0.1:6379"]
Connect and do the first write/read:
use RoadRunner\KV\KV;
use RoadRunner\KV\Item;
use Spiral\Goridge\RPC\RPC;
$kv = new KV(RPC::create('tcp://127.0.0.1:6001'));
$kv->set('greeting', 'hello'); // plain string
$kv->set('token:42', new Item('abc', ttl: 60)); // with TTL in seconds
echo $kv->get('greeting')->getValue(); // "hello"
var_dump($kv->has('token:42')); // true
$kv->delete('greeting');
First realistic use case: replace file/APCu caching of cross-worker state — feature flags, rate-limit counters, session blobs, or a distributed lock table — where every PHP worker needs to read the same value. Start with the memory driver to validate the wiring end-to-end, then swap the storage to redis once the code path works.
Where to look first: read the KV and Item class signatures in src/, and cross-reference the RoadRunner KV plugin docs for the exact RPC method names and per-driver behavior. The plugin's config is the source of truth for what the SDK is allowed to call.
Register the client once per worker, inject it. The RPC socket is persistent across requests inside a RoadRunner worker. Build KV in your container bootstrap and inject it; never new KV(RPC::create(...)) inside a controller.
$container->singleton(KV::class, fn () => new KV(RPC::create($_ENV['RR_RPC'])));
Wrap it in a repository, not a raw facade. The SDK is low-level string in / Item out. Put domain methods on a thin class (RateLimiter, FeatureFlags, SessionStore) so callers don't touch TTLs or serialization directly:
final class FeatureFlags
{
public function __construct(private KV $kv) {}
public function enabled(string $flag): bool
{
return (bool) ($this->kv->get("flags:$flag")?->getValue() ?? false);
}
}
Serialize at the boundary. The SDK stores byte strings. JSON-encode arrays/objects on write and decode on read; keep a small Codec helper so format changes are one place:
$kv->set("user:$id", json_encode($user, JSON_THROW_ON_ERROR));
$user = json_decode($kv->get("user:$id")?->getValue() ?? 'null', true);
TTL as the default policy. Pass new Item($value, ttl: $seconds) for anything cache-like. For "sliding" behavior, on a hit call set() again with the same value and a fresh TTL — the SDK has no touch/expire command, so re-set is the idiom.
Batch with mGet/mSet when hydrating a screen or warming a form. One RPC round-trip for N keys beats N calls; the RPC hop dominates latency, not the storage itself.
$items = $kv->mGet(['user:1', 'user:2', 'user:3']); // array of Item|null
$kv->mSet(['a' => new Item('1', 30), 'b' => new Item('2', 30)]);
Atomic counters for rate limits and sequence numbers. increment/decrement are the only read-modify-write operations the plugin performs server-side; use them instead of get-then-set for any shared counter:
$hits = $kv->increment("rl:$ip:" . intdiv(time(), 60));
if ($hits === 1) {
$kv->set("rl:$ip:" . intdiv(time(), 60), new Item('1', 120)); // arm TTL once
}
Segregate by concern, not by key prefix alone. Use separate named storages (cache, sessions, locks) in .rr.yaml so you can give each its own driver/retention policy and can clear() one without nuking the others.
Testing: run RoadRunner with the memory driver in CI and hit the real SDK, or mock KV in unit tests. The memory driver is process-local to the RoadRunner server, so integration tests must talk to a running instance — you cannot instantiate storage in PHP.
Symfony/Laravel integration tip: bind KV (or your repository wrapping it) as a service, and set it as the backend for anything that already accepts a cache interface — a PSR-16 adapter over get/set/delete/has is ~40 lines and immediately unlocks framework caching, sessions, and rate limiters.
kv plugin isn't enabled, calls throw Goridge/RPC exceptions. Wrap the client in a resilience layer (circuit breaker or try/catch with a sane default) for anything on the request hot path.increment/decrement, TTL granularity, and keys behavior vary between memory, redis, memcached, and boltdb. Test the operations you actually use against the driver you'll deploy; don't assume because it worked on memory.keys() and clear() are footguns in production. On Redis-backed stores this can be an expensive full scan; on a shared storage it can wipe another app's data. Gate them behind admin tooling and a dedicated storage.memory for single-instance dev, or truly worker-local scratch data.SETNX-style atomic set. There's no compare-and-swap primitive in the SDK. For locks, the common pattern is set-with-TTL + verify, or lean on a driver that supports atomic set semantics directly; for multi-key consistency, this is the wrong tool.json_encode to serialize(), old entries become garbage that only fails on read. Version your payloads or version your key prefix.prod:billing:...) at the repository layer; there is no built-in namespace.composer.lock diffs — RPC method changes between RoadRunner versions surface as runtime exceptions, not compile-time errors.KV rather than asking every caller to use it. That adapter is also the natural place to add key prefixing, JSON encoding, and fallback behavior — keep the SDK calls isolated there so an eventual swap to a native client (e.g., predis) is a single-file change.How can I help you explore Laravel packages today?