DEV Community

Cover image for PHP and gRPC: The Bidirectional Streaming Pattern Most Tutorials Skip
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP and gRPC: The Bidirectional Streaming Pattern Most Tutorials Skip


You want to call a Go service from PHP. The Go team built it with gRPC. You search "PHP gRPC tutorial" and get fifteen blog posts showing the same SayHello unary call. None of them mention streaming. The Go team's chat service is bidirectional. Your PHP service has to talk to it. Now what?

Here's what nobody tells you. PHP gRPC bidi streaming works. The grpc extension supports it. The protobuf compiler emits the right stubs. The only catch is that the official documentation pretends streaming doesn't exist past the unary case. This post walks the whole thing: protoc setup, a real bidi chat client in PHP, channel credentials, and how to wire it into a Laravel app without poisoning your HTTP request lifecycle.

gRPC in PHP: what you actually need

Three pieces. None of them ship with PHP by default.

The grpc PECL extension is the runtime. It talks HTTP/2 to gRPC servers. Install with pecl install grpc against PHP 8.2+. On Linux it builds from source and takes about three minutes. On macOS with Homebrew PHP, you'll need brew install protobuf first because the extension's build pulls in libprotobuf symbols.

The protobuf PECL extension is optional but you want it. It's a C implementation of Google's protobuf runtime. Without it, you fall back to the pure-PHP google/protobuf Composer package, which is around 10x slower on deserialization. For streaming, that gap matters. Every message you read on the wire gets decoded individually.

The protoc-gen-php-grpc plugin emits the PHP stubs. There are two variants. Google's grpc_php_plugin (ships with the gRPC source) generates classes that depend on the grpc extension's C client. Spiral's protoc-gen-php-grpc (from the RoadRunner team) generates interface-only stubs that work with their server. For client code calling out to Go/Rust/Node services, you want Google's plugin.

Install protoc itself with brew install protobuf or apt install protobuf-compiler. Then build the gRPC plugin:

git clone -b v1.62.0 https://github.com/grpc/grpc
cd grpc && git submodule update --init
make grpc_php_plugin
# binary lands at bins/opt/grpc_php_plugin
Enter fullscreen mode Exit fullscreen mode

You're now set up.

The four streaming modes (and why three of them are easy)

gRPC has four call types, defined by which side streams:

  • Unary: client sends one message, server sends one back. The HTTP request/response model you already know.
  • Server streaming: client sends one, server sends many. Useful for log tailing, search results, anything paginated where the server controls cadence.
  • Client streaming: client sends many, server sends one summary. Bulk uploads, telemetry batching.
  • Bidirectional: both sides stream independently. Chat, collaborative editing, anything event-driven.

Unary is what every tutorial covers. Server streaming and client streaming are 90% the same code shape. You call read() in a loop or write() in a loop and finishWrite() at the end. Bidi is where it gets interesting because the two streams run independently and PHP is fundamentally single-threaded.

A real bidi example: chat protocol

Here's the .proto file. A client sends ChatMessage records to a server, the server fans them out to other clients, and every client receives a stream of ChatEvent notifications:

syntax = "proto3";

package chat.v1;

option php_namespace = "App\\Grpc\\Chat\\V1";
option php_metadata_namespace = "App\\Grpc\\Chat\\V1\\Meta";

service ChatService {
  // bidi: client streams ChatMessage, server streams ChatEvent back
  rpc Connect(stream ChatMessage) returns (stream ChatEvent);
}

message ChatMessage {
  string room_id = 1;
  string user_id = 2;
  string body = 3;
  int64 sent_at_ms = 4;
}

message ChatEvent {
  oneof event {
    ChatMessage message_received = 1;
    UserJoined  user_joined = 2;
    UserLeft    user_left = 3;
  }
}

message UserJoined { string room_id = 1; string user_id = 2; }
message UserLeft   { string room_id = 1; string user_id = 2; }
Enter fullscreen mode Exit fullscreen mode

Compile it:

protoc \
  --proto_path=proto \
  --php_out=app/Grpc \
  --grpc_out=app/Grpc \
  --plugin=protoc-gen-grpc=/path/to/grpc_php_plugin \
  proto/chat.proto
Enter fullscreen mode Exit fullscreen mode

You'll get a tree under app/Grpc/Chat/V1/. The interesting class is ChatServiceClient. Open it. The method you need looks roughly like this in the generated output:

// generated by protoc-gen-php-grpc, lightly trimmed
namespace App\Grpc\Chat\V1;

class ChatServiceClient extends \Grpc\BaseStub
{
    public function Connect($metadata = [], $options = [])
    {
        return $this->_bidiRequest(
            '/chat.v1.ChatService/Connect',
            ['\App\Grpc\Chat\V1\ChatEvent', 'decode'],
            $metadata,
            $options
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

_bidiRequest returns a \Grpc\BidiStreamingCall. That object has three methods you care about: write($message), read(), and finishWrite(). Plus getStatus() for the trailing status code when the server closes its side.

Here's a working PHP client that joins a room, sends three messages, and prints every event it receives until the server closes:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use App\Grpc\Chat\V1\ChatServiceClient;
use App\Grpc\Chat\V1\ChatMessage;
use App\Grpc\Chat\V1\ChatEvent;

$client = new ChatServiceClient('chat.internal:9000', [
    'credentials' => \Grpc\ChannelCredentials::createInsecure(),
]);

// metadata travels with the initial HEADERS frame
$call = $client->Connect([
    'authorization' => ['Bearer ' . getenv('CHAT_TOKEN')],
    'x-tenant-id'   => ['acme-corp'],
]);

// send three messages, then close the write side
$userId = 'user-42';
$roomId = 'room-general';

foreach (['hello', 'is anyone there', 'ok bye'] as $body) {
    $msg = new ChatMessage();
    $msg->setRoomId($roomId);
    $msg->setUserId($userId);
    $msg->setBody($body);
    $msg->setSentAtMs((int) (microtime(true) * 1000));

    $call->write($msg);
}
$call->finishWrite(); // tells the server we're done writing

// read until the server closes its side (read() returns null)
while (($event = $call->read()) !== null) {
    /** @var ChatEvent $event */
    $which = $event->getEvent();
    switch ($which) {
        case 'message_received':
            $m = $event->getMessageReceived();
            printf("[%s] %s: %s\n", $m->getRoomId(), $m->getUserId(), $m->getBody());
            break;
        case 'user_joined':
            printf("+ %s joined %s\n", $event->getUserJoined()->getUserId(), $event->getUserJoined()->getRoomId());
            break;
        case 'user_left':
            printf("- %s left %s\n", $event->getUserLeft()->getUserId(), $event->getUserLeft()->getRoomId());
            break;
    }
}

[$status, $details] = [$call->getStatus()->code, $call->getStatus()->details];
if ($status !== \Grpc\STATUS_OK) {
    fwrite(STDERR, "stream ended with status $status: $details\n");
    exit(1);
}
Enter fullscreen mode Exit fullscreen mode

That's a complete bidi client in roughly 50 lines. Two things to notice:

$call->write() is non-blocking-ish. The C extension queues the message and returns. $call->read() blocks until the server sends an event or closes the stream. Because PHP doesn't have native concurrency, you can't truly interleave reads and writes from a single PHP process the way Go's select does. You either send everything first then read, or you switch to a loop and use read() with a deadline. More on that in the rough-edges section.

finishWrite() is the half-close. The server now knows you're done sending and can close its side when it's ready. Forgetting this is the number one bidi bug: the server hangs waiting for more input and you sit on a read() that never returns.

Channel credentials: what the docs gloss over

createInsecure() is fine for localhost testing. For production you need TLS, and the credentials API is finicky.

Three real configurations cover 95% of cases:

Public TLS (the server has a real cert chain):

$creds = \Grpc\ChannelCredentials::createSsl();
// passing null/null means: use the system root CA bundle
$client = new ChatServiceClient('chat.example.com:443', [
    'credentials' => $creds,
]);
Enter fullscreen mode Exit fullscreen mode

Pinned CA (private cluster, internal CA):

$caPem = file_get_contents('/etc/ssl/internal-ca.pem');
$creds = \Grpc\ChannelCredentials::createSsl($caPem);
Enter fullscreen mode Exit fullscreen mode

Mutual TLS (the server demands a client cert):

$caPem    = file_get_contents('/etc/ssl/internal-ca.pem');
$keyPem   = file_get_contents('/etc/ssl/client.key');
$certPem  = file_get_contents('/etc/ssl/client.crt');

$creds = \Grpc\ChannelCredentials::createSsl($caPem, $keyPem, $certPem);
Enter fullscreen mode Exit fullscreen mode

The gotcha: PHP's createSsl() expects PEM contents, not paths. If you pass a path it'll fail with a confusing Failed to create secure channel error because the C extension treats the string as cert data, not a filename. Read the file first.

For per-call auth, pass metadata on the call itself (as the chat example does with the authorization header). That metadata only travels on the initial HEADERS frame. You can't change the bearer token mid-stream. If you need rotating tokens for a long-lived stream, you have to close the call and reopen.

Where PHP gRPC is rough

Three things to know before you commit to this architecture.

No real concurrency. PHP runs one thing at a time. If your bidi stream needs to send a message while waiting on a read, you have two options: switch to non-blocking read() with a tiny deadline and poll (busy loop, wastes CPU), or run the gRPC call in a separate worker process and message it from your web request (queue bridge, see next section). There's no swoole-free way to truly interleave I/O on a single channel.

Error handling is a status code, not an exception. Unlike Guzzle's ConnectException, a broken gRPC stream returns null from read() and you check getStatus() for the failure code. New developers write if ($event === null) break; and miss that the stream died with STATUS_UNAVAILABLE. Always check status after the loop.

Memory leaks in long-lived streams. The grpc extension has had ref-count bugs around streaming calls (see the v1.50.x to v1.60.x changelog). A bidi connection held open for hours under heavy throughput can leak FDs. The fix the gRPC team recommends: close and reopen the call every 10k messages or every 30 minutes, whichever comes first. Annoying but real.

Using gRPC from Laravel: the queue-bridge pattern

You don't want a gRPC bidi stream inside a Laravel HTTP request. PHP-FPM kills your worker if the response takes more than 30 seconds; nginx times out at 60. Your stream will be cut.

The pattern that works: bridge gRPC to your Laravel app via the queue.

The web request enqueues a job. A long-running worker process (started via php artisan queue:work --timeout=0 --memory=512) picks up the job and runs the gRPC call. Results come back through Redis pub/sub, Laravel Echo, or a database table the front-end polls.

A minimal version:

namespace App\Jobs;

use App\Grpc\Chat\V1\ChatServiceClient;
use App\Grpc\Chat\V1\ChatMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;

class RelayChatStream implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 0; // worker handles its own lifetime

    public function __construct(
        public string $roomId,
        public string $userId,
        public string $sessionId,
    ) {}

    public function handle(): void
    {
        $client = new ChatServiceClient(config('grpc.chat.host'), [
            'credentials' => \Grpc\ChannelCredentials::createSsl(
                file_get_contents(config('grpc.chat.ca_path'))
            ),
        ]);

        $call = $client->Connect([
            'authorization' => ['Bearer ' . config('grpc.chat.token')],
        ]);

        // outgoing: read from Redis list the web layer pushes to
        // incoming: write to a Redis pub/sub channel the web layer subscribes to
        $outboundKey = "chat:out:{$this->sessionId}";
        $inboundChan = "chat:in:{$this->sessionId}";

        // pump loop. read() blocks; we wake every 100ms to check outbound
        // (this is the "no true concurrency" workaround)
        while (true) {
            $event = $call->read();
            if ($event === null) break;

            Redis::publish($inboundChan, $event->serializeToJsonString());

            // drain any pending outbound messages
            while ($payload = Redis::lpop($outboundKey)) {
                $msg = new ChatMessage();
                $msg->mergeFromJsonString($payload);
                $call->write($msg);
            }
        }

        if ($call->getStatus()->code !== \Grpc\STATUS_OK) {
            $this->fail(new \RuntimeException(
                "chat stream failed: " . $call->getStatus()->details
            ));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The trick is that the queue worker process is the one holding the gRPC channel open. The web layer pushes outbound messages onto a Redis list and listens to a pub/sub channel for inbound. From the user's browser, the experience is a normal WebSocket or SSE connection to your Laravel app. They never touch gRPC. Your PHP frontend stays cleanly synchronous; the streaming complexity lives in the worker.

One detail: Redis::lpop is also blocking-ish if you use blpop with a small timeout, which is what you actually want in production. The above uses non-blocking lpop for clarity.

When REST + SSE is the right call

Real talk: most teams don't need this. If the upstream service offers both REST and gRPC, and you're a PHP shop, REST plus Server-Sent Events covers 80% of what you'd reach for bidi gRPC for. SSE is HTTP/1.1, plays nicely with PHP-FPM, and works through every corporate proxy.

Pick gRPC bidi when:

  • The upstream service is gRPC-only (you don't get a vote).
  • You need backpressure on both sides (SSE gives you only server-to-client flow control).
  • You're already running RoadRunner or Swoole and the concurrency model fits.

Pick REST + SSE when you have the choice and your team has never read this article before. Boring tech ships.

If you're stuck on the gRPC path, this post is your reference. Go service teams love bidi streaming. PHP teams can talk to them. The tooling is decent. The docs are bad. Now you have the missing chapter.

What's your current pattern for PHP talking to a gRPC service: queue bridge, RoadRunner, or just falling back to REST? Drop it in the comments.


If this was useful

Wiring PHP into a gRPC streaming protocol is the kind of decision that lives inside the integration layer of your app: a port to an external system that should never leak into your domain code. Decoupled PHP covers the architectural layer your codebase reaches for once the framework defaults stop carrying their weight, including how to keep transports like gRPC, Kafka, and webhooks behind clean adapters so swapping them later is a Tuesday afternoon and not a quarter-long migration.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)