spiral/grpc-client
Powerful, extensible gRPC client for PHP with a simple Guzzle-like API. Make unary and streaming calls, add interceptors like timeouts and retries, and integrate easily with Spiral or use standalone. Requires the gRPC extension.
Prerequisites (do these first, they are the real blockers):
pecl install grpc (or docker-php-ext-install grpc in a Dockerfile), then enable it. Verify with php -m | grep grpc. Nothing in this package works without it..proto files with protoc + the protoc-gen-php-grpc plugin (Spiral/RoadRunner tooling). This package only works with interfaces generated by that plugin — you get \GRPC\MyService\MailSenderInterface plus request/response message classes.composer require spiral/grpc-client -W. The -W matters: this is a 1.0.0-rc2 package, so Composer's minimum-stability rules will fight you. Pin an exact version ("spiral/grpc-client": "1.0.0-rc2") rather than floating.First use case — the smallest possible thing that works. Don't start with framework integration. Start standalone, in tinker or a throwaway command:
use Spiral\Grpc\Client\GrpcClient;
use Spiral\RoadRunner\GRPC\Context;
$client = GrpcClient::create('localhost:9001');
$mailSender = $client->service(\GRPC\MyService\MailSenderInterface::class);
$response = $mailSender->sendMail(
new Context([]),
(new \GRPC\MyService\SendMailRequest())
->setEmail('[email protected]')
->setSubject('hi')
->setMessage('hello'),
);
That's the whole loop: create → service() → call. Once you've seen one round trip succeed, add a timeout:
$client = GrpcClient::create('localhost:9001')
->withInterceptors([
\Spiral\Grpc\Client\Interceptor\SetTimeoutInterceptor::createConfig(5_000),
\Spiral\Grpc\Client\Interceptor\RetryInterceptor::createConfig(maximumAttempts: 3),
]);
Where to look first, in order: GrpcClient (the standalone facade — read its with* methods, they are the whole public surface), then Config\GrpcClientConfig / Config\ServiceConfig / Config\ConnectionConfig (the DTO tree you'll wire in a Laravel service provider), then Interceptor\* for the built-ins, then Interceptor\Helper when you write your own.
Laravel note up front: this package's native home is Spiral/roadrunner-bridge (GrpcClientBootloader). In Laravel you are on the "other frameworks" path: either the standalone GrpcClient, or ServiceClientProvider + GrpcClientConfig. Expect to write ~20 lines of AppServiceProvider glue; there is no Laravel package for it.
Bind generated interfaces into the container, not the client. The ergonomic win is constructor-injecting \GRPC\MyService\MailSenderInterface and never touching GrpcClient again. Register a singleton client and resolvers in a service provider:
// AppServiceProvider::register()
$this->app->singleton(GrpcClient::class, fn () => GrpcClient::create(
new ConnectionConfig(
address: config('grpc.mailer.address'),
tls: new TlsConfig(
certChain: config('grpc.tls.cert'),
privateKey: config('grpc.tls.key'),
),
),
)->withInterceptors([
new SetTimeoutInterceptor(5_000),
RetryInterceptor::createConfig(maximumAttempts: 3, initialInterval: 100),
]));
// one binding per generated interface
$this->app->bind(
\GRPC\MyService\MailSenderInterface::class,
fn ($app) => $app->make(GrpcClient::class)
->service(\GRPC\MyService\MailSenderInterface::class),
);
That single binding is the highest-leverage move you can make: consumers become plain, testable PHP with a typed interface, and swapping to a fake in tests is a one-line $this->app->instance(...).
Derive clients per consumer instead of one god-client. with* is immutable — each call returns a new instance. Use that deliberately:
protected function grpc(): GrpcClient
{
return GrpcClient::create(config('grpc.addresses'))
->withInterceptors([SetTimeoutInterceptor::createConfig(2_000)]);
}
// a slow reporting service gets its own derived client
$reporting = $this->grpc()->withInterceptors([
SetTimeoutInterceptor::createConfig(30_000),
RetryInterceptor::createConfig(maximumAttempts: 2),
]);
This is also the safe pattern for "we need retries on writes but not on reads" — derive, don't mutate config at runtime.
Reach for DTO config when you need per-service behaviour. The standalone client has no per-service interceptors; that capability only exists through ServiceClientProvider + GrpcClientConfig + ExecuteServiceInterceptors. The shape:
new GrpcClientConfig(
interceptors: [
SetTimeoutInterceptor::createConfig(10_000),
ExecuteServiceInterceptors::class, // <-- must be present
],
services: [
new ServiceConfig(
connections: new ConnectionConfig('mailer:9001'),
interfaces: [\GRPC\MyService\MailSenderInterface::class],
interceptors: [/* service-only */],
),
],
);
Multi-connection is not automatically load balancing. GrpcClient::create(['host1:9001', 'host2:9001']) gives you a list of targets; the orchestration strategy comes from an interceptor (ConnectionsRotationInterceptor, or your own). If you want failover, add the interceptor — otherwise you've configured nothing.
Write interceptors for cross-cutting concerns, not for business logic. This is the extension point that earns its keep day-to-day: auth token propagation, tenant headers, request IDs, latency metrics. The pattern is always "read from CallContextInterface, transform via Helper, delegate":
final class RequestIdInterceptor implements InterceptorInterface
{
public function __construct(private readonly RequestContext $ctx) {}
public function intercept(CallContextInterface $context, HandlerInterface $handler): mixed
{
$metadata = Helper::withMetadata($context); // copy
$metadata['x-request-id'] = [$this->ctx->requestId()];
return $handler->handle(Helper::withMetadata($context, $metadata));
}
}
Metadata values are arrays of strings — [$token], not $token. That's the one thing everyone gets wrong on the first try.
Interceptors are recreated per call and live in the caller's scope. That's a feature: you can constructor-inject AuthContextInterface, Illuminate\Http\Request, tenant resolvers, or metrics collectors, and each interceptor instance is disposable and stateful. So an interceptor holding a $startTime or a retry counter is safe — just never hoist interceptor objects into long-lived static/singleton state yourself.
Guard the boundary with your own port. Because the interface you inject is generated code (\GRPC\...), you're coupling your app to regenerated artefacts. A thin domain interface with a gRPC-backed adapter (mapping generated messages to your DTOs) keeps protoc churn out of your controllers and makes the "what if we go back to REST" question cheap.
Interceptor order is the whole semantics, and it's not what you'd guess. In [SetTimeout(10s), Retry(3, 2s), SetTimeout(3s)] you get a 3s deadline per attempt and a 10s budget across all attempts — because RetryInterceptor reads the previously-configured timeout. Put the outer budget timeout first, the per-attempt timeout after RetryInterceptor. Getting this backwards produces retries that all fire after the caller has already given up.
ExecuteServiceInterceptors is the most common "why is nothing happening" bug. Service-level interceptors are only executed if that class is present in the global interceptor list — and where you place it determines where the service interceptors land in the chain. Omit it and your service-specific auth/retry silently never runs.
with* returns a new object. $client->withInterceptors([...]); followed by $client->service(...) silently uses the old configuration. Always assign. Same for withFactory.
Interceptors must be resolved, not new-ed, if they need DI. With the standalone client, pass class-strings and supply a factory (withFactory($container)); otherwise constructor-injected interceptors blow up. Verify the factory signature your container offers actually matches what the package expects — Laravel's make($abstract, array $parameters) is close to Spiral's FactoryInterface, but if it doesn't line up, write a two-method adapter rather than inlining new.
Only protoc-gen-php-grpc output works. Interfaces generated by grpc/grpc's own protoc-gen-php (the generic \Grpc\ stubs) are not compatible. If service(SomeInterface::class) fails oddly, check which generator produced the file before debugging anything else.
Context is RoadRunner's, not Laravel's. new \Spiral\RoadRunner\GRPC\Context([]) is what you pass on every call. Deadlines and outgoing metadata live there; if you find yourself reaching for a Request object in a service method, you've skipped a layer — put it in an interceptor.
TLS file paths are resolved by the FPM/CLI user, not yours. TlsConfig(certChain: ..., privateKey: ...) failing with an opaque handshake error is almost always a permissions or container-mount problem. is_readable() the paths in a health check.
Pin the version and read the changelog on every bump. 40 stars, 0 dependents, RC releases where the changelog literally says "signatures of some public classes may still change" — ConnectionConfig was already refactored once in rc1. Treat upgrades as code review, and keep your GrpcClient construction confined to one provider so a breaking change is a one-file fix.
Debugging technique: add a logging interceptor as the outermost one. Log the service method, deadline, metadata keys (not values — tokens), elapsed time, and whether you're on attempt n. gRPC failures surface as status codes (UNAVAILABLE, DEADLINE_EXCEEDED) that are meaningless in a stack trace; a logging interceptor gives you the sequence. Drop it in as the first and last interceptor to see the full envelope.
Catch the package's dedicated exception types, not \Throwable. The package ships purpose-built exceptions precisely so you can distinguish "the remote service said no" from "we couldn't reach it" and decide between retry, fallback, and user-facing error. Catching broadly throws that information away.
Missing observability is your job to add. There's no OpenTelemetry or structured logging baked in. If you need trace propagation, that's a custom interceptor writing traceparent into metadata — cheap to build, but budget for it rather than discovering the gap during an incident.
How can I help you explore Laravel packages today?