roadrunner/grpc
RoadRunner gRPC lets you build high-performance gRPC services in PHP on the RoadRunner application server. It combines protobuf-generated stubs with persistent workers, supports unary and streaming RPCs, and avoids per-request bootstrap overhead for fast, production-ready microservices.
Install. You need two things: the PHP SDK and the RoadRunner binary.
composer require roadrunner-php/grpc spiral/roadrunner-worker
# binary (pick one)
./vendor/bin/rr get-binary # via roadrunner-cli
# or download from github.com/roadrunner-server/roadrunner/releases
The stock rr binary ships the grpc plugin compiled in — you do not need the PHP ext-grpc, libgrpc, or OpenSSL. That is the whole point of the package: HTTP/2 and connection management happen in Go, PHP just serializes protobuf messages.
Where to look first.
src/Server.php, src/ServiceInterface.php, src/Context.php — server side (you implement services).src/Invoker.php, src/InvokerInterface.php — client side (you call services).src/Exception/ — the gRPC status-code exception hierarchy; this is your error contract.protoc plugin binary installed to vendor/bin (protoc-gen-php-grpc) — it generates the interfaces/stubs the SDK expects, not ext-grpc stubs.proto/ + README worker example — copy the worker loop verbatim, it is the least obvious part.Minimal wiring. .rr.yaml needs a rpc endpoint (the client invoker talks to the plugin over it) and a grpc section with the same .proto files you generated code from:
version: '3'
rpc:
listen: tcp://127.0.0.1:6001
grpc:
listen: tcp://127.0.0.1:9001
proto:
- proto/helloworld.proto
pool:
num_workers: 4
command: php worker.php
First use case — unary hello world.
protoc -I proto \
--php_out=./generated \
--php-grpc_out=./generated \
proto/helloworld.proto
// worker.php
use RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunner\Payload;
$server = new Server();
$server->registerService(Helloworld\GreeterInterface::class, new GreeterService());
$worker = Worker::create();
while ($payload = $worker->waitPayload()) { // loop shape from the README
try {
$worker->respond(new Payload($server->serve($payload->body)));
} catch (\Throwable $e) {
$worker->error($e->getMessage());
}
}
final class GreeterService implements Helloworld\GreeterInterface
{
public function SayHello(RoadRunner\GRPC\ContextInterface $ctx, Helloworld\HelloRequest $in): Helloworld\HelloReply
{
return (new Helloworld\HelloReply())->setMessage('Hello ' . $in->getName());
}
}
Call it from another RoadRunner worker (HTTP, queue, etc.):
$invoker = new RoadRunner\GRPC\Invoker($worker); // usually a DI singleton
$client = new Helloworld\GreeterClient($invoker, '127.0.0.1:9001');
$reply = $client->SayHello(new RoadRunner\GRPC\Context(), (new HelloRequest())->setName('Anton'));
echo $reply->getMessage();
Keep codegen on rails. Two protoc flags, one output dir: messages via --php_out, service interfaces + client stubs via the package's gRPC plugin. Commit generated code (it diffs badly but reviews well against .proto changes), and treat proto/ as the single source of truth shared between .rr.yaml, codegen, and any Go/other-language consumers. A make proto target beats tribal knowledge.
Server: one class per proto service, one method per RPC. The service name comes from package + service in the .proto (helloworld.Greeter) and is baked into the generated interface — never hand-write an implementation of ServiceInterface with a guessed getName(). Register services once at worker boot; the loop should never re-register. Keep service classes thin: they are the transport boundary, so delegate to real application services, return proto messages only, and never return Eloquent models.
Context is your metadata bag. Server: $ctx->getValue('authorization') / $ctx->getValues() for incoming headers (keys are lowercased HTTP/2 names). Client: pass new Context(['authorization' => 'Bearer …', 'x-request-id' => $id]). Values are strings — serialize anything structured yourself.
Errors are the contract, not stack traces. Throw the SDK's typed exceptions and let the mapping do the work:
use RoadRunner\GRPC\Exception\{NotFoundException, InvalidArgumentException, UnavailableException};
if (!$user) throw new NotFoundException("User {$id} not found");
Each maps to a canonical status code that Go clients (and grpc-gateway, Envoy, retry policies) understand. Anything else is genericised — which is good, because raw PDOException messages would otherwise leak SQL to remote callers.
Client: inject InvokerInterface, not concrete clients. Bind it as a singleton in your service container and construct generated clients per target address. This gives you one extension point for everything cross-cutting — logging, timing, retry-on-Unavailable, metrics, request-id propagation:
final class LoggingInvoker implements RoadRunner\GRPC\InvokerInterface
{
public function __construct(private InvokerInterface $inner, private LoggerInterface $log) {}
public function invoke(/* method, ctx, in, out */): void
{
$start = microtime(true);
try {
$this->inner->invoke(...func_get_args());
} finally {
$this->log->debug('grpc.call', ['ms' => (microtime(true) - $start) * 1000]);
}
}
}
Decorating the invoker is the cleanest analogue to a client interceptor in this package.
Streaming is modelled as generators/iterables. Generated signatures for server-streaming return iterable/\Generator; you yield messages as they are produced. Remember every open stream holds a worker out of the pool for its lifetime — do not stream from a pool sized for unary traffic only.
Testing. Because everything goes through InvokerInterface, you can unit-test client-facing code with a fake invoker. For the server side you can call your service class directly (new GreeterService()->SayHello(new Context([...]), $request)) — no rr needed — and reserve rr serve for a small integration suite. Assert on exception classes and metadata, not on protobuf binary strings.
Composition in a Laravel app. One service provider: bind InvokerInterface, register generated clients as singletons keyed by target address, expose a Grpc::client(GreeterClient::class) helper or facade. Controllers stay ignorant of the transport. For long-lived queue consumers, remember the worker still needs the rpc plugin connection alive — which it has, since jobs workers are RoadRunner workers.
Invoker calls the local plugin over the RPC socket — there is no direct dial from PHP. php artisan commands, tinker, PHPUnit run from CLI, and php -S scripts will fail with "RPC not available" / connection refused. Your first debugging hour is usually spent discovering this.rpc.listen is mandatory. Adding grpc: to .rr.yaml without an rpc: section gives a working server and a silently broken client..proto list in grpc.proto must match the files you generated from. Stale worker code against new protos yields "cannot parse invalid wire-format data" or UNKNOWN with no useful message — not a schema error. Regenerate, restart rr, and re-check the list.getName() once when onboarding a service.\Throwable in the worker loop. An escaping exception takes the worker down; the plugin restarts it, but you lose the request and pay restart cost. respond()/error() on every branch.hasFoo() is only generated for message fields and optional scalars. Empty string / 0 / false is indistinguishable from "unset" for plain scalars — a real correctness trap for PATCH-style APIs. Use optional or google.protobuf.*Value wrappers where "not sent" matters.x-tenant-id).grpc.pool.allocate_timeout, request timeouts). If you need hard deadlines, enforce them above the call and treat the orphaned request as an at-least-once hazard.ext-protobuf is a large, near-free win. Watch message sizes — every request/response is copied through the Go↔PHP socket, so multi-megabyte payloads turn a worker pool into a memory multiplier. Prefer pagination/streaming over fat messages.v2023-era PHP SDK against a v2025 binary produces confusing payload/protocol failures rather than clean errors.rr serve -d -v for plugin logs, rr workers to watch pool saturation, grpcurl pointed at grpc.listen to sanity-check the service is actually registered (independent of PHP), and protoc --decode_raw on a payload dump when wire format looks wrong. A stuck pool with all workers busy almost always means a stream was never closed or a service call is blocking on a slow upstream with no timeout.InvokerInterface (retries, metrics, tracing), a custom Context/ContextInterface for typed metadata, exception subclasses for domain-specific codes, and wrapping service implementations with your own middleware instead of pushing logic into generated classes.rr serve, and prefer the boring unary path until you truly need streaming.How can I help you explore Laravel packages today?