spiral/roadrunner-grpc
High-performance PHP gRPC server built on RoadRunner. Run PHP and Go services in one app with native gRPC compliance, TLS, middleware, Prometheus metrics, and protoc code generation. Map response error codes to PHP exceptions.
This is a worker-side SDK, not a client and not a Laravel package. It gives you the PHP classes that the RoadRunner gRPC plugin (a Go server) proxies into: Server, Invoker, ContextInterface, Method, and the exception/status-code types. If you are not already running RoadRunner (Octane with the roadrunner driver, or a standalone RR setup), stop and fix that first — nothing here works under PHP-FPM.
1. Install and fetch the toolchain. As of v3.8.0 the package has been renamed — it is now roadrunner/grpc (previously spiral/roadrunner-grpc). New projects use the new name; existing projects should migrate. Install ^3.8.1, not 3.8.0: the 3.8.0 tag had a wrong dependency and required the old spiral/roadrunner metapackage, which can fail to resolve or pull an abandoned package (see Gotchas).
# new projects
composer require roadrunner/grpc:^3.8.1 google/protobuf
# existing projects (v3.7.x and below)
composer remove spiral/roadrunner-grpc
composer require roadrunner/grpc:^3.8.1
composer require --dev spiral/roadrunner-cli # vendor/bin/rr get-binary
# download protoc-gen-php-grpc from https://github.com/roadrunner-server/roadrunner/releases
The PHP namespaces (Spiral\RoadRunner\GRPC\...) are unchanged by the rename — only the Composer package name moved. Grep your composer.json, CI configs, and Dockerfiles for the old names (spiral/roadrunner-grpc, spiral/roadrunner); nothing in your use statements should need to change, but verify against vendor/roadrunner/grpc/src after upgrading.
2. Describe one method and generate stubs. Start with a single unary call — it is the fastest way to prove the whole chain works.
syntax = "proto3";
package billing.v1;
option php_namespace = "App\\Grpc\\Billing\\V1";
option php_metadata_namespace = "App\\Grpc\\Billing\\V1\\GPBMetadata";
service InvoiceService {
rpc Get(GetInvoiceRequest) returns (GetInvoiceResponse);
}
message GetInvoiceRequest { string id = 1; }
message GetInvoiceResponse { string id = 1; int64 total_cents = 2; string status = 3; }
protoc --plugin=protoc-gen-php-grpc=./protoc-gen-php-grpc \
--php_out=generated --php-grpc_out=generated proto/billing/v1/invoice.proto
You get message classes and an InvoiceServiceInterface — you do not need grpc/grpc or the PECL extension for this.
3. Implement the interface, booting Laravel once. The worker is long-lived, so bootstrap the framework outside the service class.
// grpc-worker.php
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); // Laravel 11: bootstrap/app.php handles this
use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\GRPC\Invoker;
$server = new Server(new Invoker(), [
'billing.v1.InvoiceService' => $app->make(App\Grpc\InvoiceService::class),
]);
$server->serve();
final class InvoiceService implements InvoiceServiceInterface
{
public function __construct(private InvoiceRepository $invoices) {}
public function Get(ContextInterface $ctx, GetInvoiceRequest $in): GetInvoiceResponse
{
$invoice = $this->invoices->findOrFail($in->getId());
return (new GetInvoiceResponse())
->setId($invoice->id)
->setTotalCents($invoice->total_cents)
->setStatus($invoice->status);
}
}
4. Point RoadRunner at it in .rr.yaml. v3.8.0 adds support for RoadRunner v3, so this is the config shape you should be targeting now (older RR majors used slightly different keys — mirror the version pinned by your roadrunner/roadrunner metapackage and your rr binary):
version: '3'
rpc:
listen: 'tcp://127.0.0.1:6001'
grpc:
listen: 'tcp://127.0.0.1:9001'
proto:
- 'proto/billing/v1/invoice.proto'
pool:
command: 'php grpc-worker.php'
num_workers: 4
max_jobs: 500
5. Smoke test from outside PHP.
./rr serve -c .rr.yaml
grpcurl -plaintext -import-path proto -proto billing/v1/invoice.proto \
-d '{"id":"inv_1"}' 127.0.0.1:9001 billing.v1.InvoiceService/Get
First real use case: an internal, typed call between two Laravel services (e.g. Orders → Billing) where you want a compile-time contract and predictable latency instead of a JSON round trip.
Thin adapter + container-resolved domain services. Treat the generated service class as a transport adapter: validate/convert protobuf input, delegate to a domain service, map the domain object back to a protobuf message. Resolve domain services from the container in the worker script (once) or via app()->make() per call if they hold request state. Do not put Eloquent queries inline in the RPC method — you will want the same logic callable from HTTP and from queued jobs.
One worker script per plugin, one Laravel kernel per worker. Build a small bootstrap/grpc.php that returns the booted $app and share it between your gRPC worker and any Octane worker. Whatever you configure in config/octane.php (warmup, OperationTerminated listeners, flush of scoped instances) is not automatically applied to gRPC requests — replicate it in a middleware (see below) or call the same flush routine at the end of each handler.
Middleware for the cross-cutting stuff you lost with HTTP. Invoker accepts middlewares (Spiral\RoadRunner\GRPC\MiddlewareInterface); authentication, request logging, metrics, and per-request context cleanup all belong there rather than in every handler.
$invoker = new Invoker([new GrpcLoggingMiddleware($log), new GrpcAuthMiddleware($tokens)]);
$server = new Server($invoker, ['billing.v1.InvoiceService' => $service]);
Auth pattern: read the bearer token from request metadata exposed on the context ($ctx->getValue('authorization')), verify it, and either bind the resolved user into the container (app()->instance(...)) or throw a GRPCException with StatusCode::UNAUTHENTICATED. Bind it in the middleware and release it in a finally — this is the closest analogue to Laravel's auth middleware and the only reliable way to get an "authenticated user" into your handlers.
Error contract: map domain exceptions to status codes once. Any uncaught PHP exception becomes an opaque UNKNOWN for the client. Catch your domain exceptions in one place (a middleware is ideal) and rethrow as GRPCException with a Spiral\RoadRunner\GRPC\StatusCode constant so callers can branch on the code instead of parsing strings. Use rich google.rpc details for validation failures so clients get field-level errors. Since v3.5.0, response headers travel back with error metadata, which is useful for correlation IDs on failures.
try {
$invoice = $this->invoices->findOrFail($id);
} catch (ModelNotFoundException) {
throw new GRPCException('Invoice not found', StatusCode::NOT_FOUND);
} catch (DomainException $e) {
throw new GRPCException($e->getMessage(), StatusCode::FAILED_PRECONDITION);
}
Never leak internals through error messages. gRPC status messages are visible to the caller; log the exception with full stack trace via PSR-3 and return a curated message.
Streaming for bulk reads. Server-streaming is the natural fit for exports and feeds: yield one message per row while using lazy()/chunkById() on the query so memory stays flat. Do not build a giant array and then stream it — you get streaming latency with batch memory. For client-streaming (uploads, batching), remember messages arrive as the client sends them; accumulate deliberately and enforce a size cap.
Client side is a separate concern. This package does not make outbound calls. From Laravel, use the PECL grpc extension with stubs from the same .proto (or grpc/grpc if you can't install the extension), and centralise the channel/credential setup in a small factory bound in the container so deadlines, interceptors, and retries are consistent. If browsers or simple HTTP partners need the same data, put a grpc-gateway/REST facade in front rather than duplicating handlers.
Codegen as a build step. Keep .proto files in the repo, generate into a generated/ directory in CI (or commit it and fail the build if make proto produces a diff). Namespace everything by version (billing.v1) from day one — renaming packages after consumers exist is painful. The same discipline now applies to the PHP package itself: the spiral/roadrunner-grpc → roadrunner/grpc rename in v3.8.0 (and the spiral/roadrunner → roadrunner/roadrunner dependency fix in 3.8.1) is exactly the kind of churn you avoid by pinning and upgrading deliberately.
Testing. Unit-test the service class by constructing it with fake domain services and calling methods directly with a hand-built request message — no server, no RoadRunner needed. For integration, grpcurl (or buf curl) against a locally served RR instance is far faster to debug than writing a PHP gRPC client just for tests. Add a health-check RPC and wire it into your orchestrator's liveness probe.
spiral/roadrunner-grpc → roadrunner/grpc. Update composer.json, lock files, CI caches, Dockerfiles, dependabot/renovate configs, and any composer show | grep spiral checks. PHP namespaces are unchanged, so application code should be untouched — but run the test suite before assuming.roadrunner/roadrunner instead of the old spiral/roadrunner metapackage. Two practical consequences. First, pin roadrunner/grpc:^3.8.1 — 3.8.0 can fail to install or drag the deprecated metapackage into your lock file. Second, if your app requires the RoadRunner metapackage directly, it should be roadrunner/roadrunner too; a leftover spiral/roadrunner in composer.json/composer.lock is now dead weight and may cause duplicate/conflicting installs. composer why roadrunner/roadrunner and composer why spiral/roadrunner are the fastest way to check what pulled what.'billing.v1.InvoiceService', not 'InvoiceService'. A mismatch produces a confusing "service not found" at request time, with nothing in the PHP logs because your worker was never invoked.web/api groups, throttle, and StartSession are all irrelevant. Rate limiting must be reimplemented (or delegated to RR/ingress).request() helper. $ctx is your request. Metadata arrives as context values; anything you need downstream (user, trace ID) must be bound into the container explicitly and unbound afterwards.pool.max_jobs (e.g. 500–1000) as a safety valve and flush scoped/request instances after each call.PrepareDbConnection-style logic applies equally here; also set a sane pool.max_jobs and connection wait_timeout shorter than the DB's.UNAVAILABLE; RoadRunner restarts the worker. Write to stderr or a PSR-3 logger before rethrowing, and tail ./rr serve output / .rr/rr.log when debugging. rr workers lists pools and restarts.RuntimeException out of a handler becomes UNKNOWN/internal. Always convert to GRPCException with a StatusCode constant.setName(null) throws a TypeError; guard optional fields and use getX() defaults carefully — proto3 scalars default to ''/0, which is not the same as "unset". Prefer wrapper types or optional for genuinely nullable values.php_namespace/php_metadata_namespace in the proto rather than hand-editing generated files, and don't manually require generated files in the worker — let Composer autoload them (add the generated/ dir to autoload.psr-4 and dump).serve() blocks and must be last. Any code after it never runs. All setup — container boot, middleware construction, service instantiation — has to happen before.num_workers: 4, four slow calls starve everything else. Push heavy work to a queue and return a job ID; size num_workers against CPU, not against concurrency wishes..rr.yaml, not PHP (grpc.tls). Termination at an ingress/LoadBalancer is usually simpler; if you do it in RR, remember grpcurl then needs -cacert/-authority instead of -plaintext.proto: in the config. Without it the server has no descriptor set, which breaks reflection and confuses grpcurl (you'll be forced to pass -proto explicitly on every call). It's also the first thing to check when methods "aren't found" after you add one.Invoker middleware (auth/logging/error mapping), the RoadRunner gRPC plugin's built-in Prometheus metrics for QPS/latency without writing any code, and the StatusCode constants class as the single source of truth for your public error contract.How can I help you explore Laravel packages today?