·4 min read ·Muhammet Şafak

PHP produces, Go consumes — over Redis

A PHP producer and a Go worker share one Redis queue, exchanging the identical JSON envelope — no shared class, and no PHP runtime on the Go side.

A PHP producer writes a job to a Redis list, and a Go worker reads the same bytes off the same list — no shared class, no PHP runtime on the Go side. This works because both ends speak one wire contract: the canonical BabelQueue envelope at schema_version: 1, routed by a URN instead of a class name.

The code below is the redis-orders example, running against the published 1.0 SDKs. Start Redis (docker run -d -p 6379:6379 redis:7), run the PHP producer once, then run the Go worker.

The producer: PHP, framework-less

The PHP core ships an EnvelopeCodec and an optional RedisTransport (a thin wrapper over predis that does a plain RPUSH). A job is anything implementing PolyglotJob — a stable URN plus a pure-JSON payload:

use BabelQueue\Codec\EnvelopeCodec;
use BabelQueue\Transport\RedisTransport;
use Predis\Client;

$queue     = 'orders';
$transport = new RedisTransport(new Client('redis://localhost:6379/0'), $queue);

$messages = [
    new OrderMessage('urn:babel:orders:created', ['order_id' => 1042, 'amount' => 99.90, 'currency' => 'USD']),
    new OrderMessage('urn:babel:orders:created', ['order_id' => 1043, 'amount' => 12.50, 'currency' => 'EUR']),
    new OrderMessage('urn:babel:catalog:item.indexed', ['sku' => 'WIDGET-1', 'title' => 'Café Widget ☕']),
];

foreach ($messages as $message) {
    $envelope = EnvelopeCodec::fromJob($message, $queue);
    $transport->publish(EnvelopeCodec::encode($envelope), $queue);
}

EnvelopeCodec::fromJob($message, $queue) builds the envelope; encode(...) turns it into the JSON string that lands on the queue. RedisTransport::publish takes that string and runs RPUSH orders. Nothing PHP-specific touches the wire — no serialize(), no class name, no object graph.

Here is one of the encoded envelopes, exactly as it sits in Redis:

{
  "job": "urn:babel:catalog:item.indexed",
  "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
  "data": { "sku": "WIDGET-1", "title": "Café Widget ☕" },
  "meta": { "id": "0f8a…", "queue": "orders", "lang": "php", "schema_version": 1, "created_at": 1750809600000 },
  "attempts": 0
}

The identity is the URN in job. The payload is plain JSON in data. The only field that announces the producer is meta.lang: "php" — and the consumer treats that as information, not a dependency.

The consumer: Go, no PHP in sight

The Go SDK is a codec plus an optional App runtime. Broker drivers live in separate modules, so the core stays dependency-free; here we pull in the /redis transport, which uses the same reliable-queue pattern (RPUSH to produce, BLMOVE to reserve, LREM to ack):

import (
    babelqueue "github.com/babelqueue/babelqueue-go"
    bqredis "github.com/babelqueue/babelqueue-go/redis"
)

transport, _ := bqredis.New("redis://localhost:6379/0")
defer transport.Close()

app := babelqueue.NewApp(transport, babelqueue.WithDefaultQueue("orders"))

app.Handle("urn:babel:orders:created", func(_ context.Context, env babelqueue.Envelope) error {
    fmt.Printf("[go] order created  id=%v amount=%v %v  trace=%s  (produced by %q)\n",
        env.Data["order_id"], env.Data["amount"], env.Data["currency"], env.TraceID, env.Meta.Lang)
    return nil
})
app.Handle("urn:babel:catalog:item.indexed", func(_ context.Context, env babelqueue.Envelope) error {
    fmt.Printf("[go] item indexed   sku=%v title=%q  (produced by %q)\n",
        env.Data["sku"], env.Data["title"], env.Meta.Lang)
    return nil
})

processed, _ := app.Drain(ctx, "orders", 0)

app.Handle(urn, handler) registers a handler keyed by URN. app.Drain(ctx, "orders", 0) processes everything currently queued and returns the count — handy for a one-shot run; a long-lived worker calls app.Consume(ctx) instead, which blocks and routes by URN until the context is cancelled.

The handler reads env.Data as a Go map, env.TraceID as a string, and env.Meta.Lang to see who produced the message. There is no generated stub, no shared schema package between the two services — just the envelope contract.

What survived the language boundary

Run the producer, then the consumer, and the Go side prints messages it never wrote:

[go] order created  id=1042 amount=99.9 USD  trace=7b3f9c2a-…  (produced by "php")
[go] order created  id=1043 amount=12.5 EUR  trace=…           (produced by "php")
[go] item indexed   sku=WIDGET-1 title="Café Widget ☕"        (produced by "php")
[go] processed 3 message(s) — same envelope, different language.

Three things crossed intact, and they are the point:

  • The trace_id the producer generated arrives unchanged at the consumer. Preserving it across every hop is a contract invariant, so an end-to-end trace stitches together regardless of which language handled each step.
  • Unicode in dataCafé Widget ☕ — round-trips byte-for-byte. The envelope is UTF-8 JSON; no escaping surprises, no mojibake.
  • The structured payload stays structured. order_id is a number, currency is a string, and the Go map reads them as such — because data was JSON the whole way, never a PHP-serialized blob.

(The amount field uses a JSON number here for a runnable demo; in production, represent money as minor units or a decimal string rather than a float.)

Why this holds

The two services never share a type. They agree on one document: the frozen wire envelope. PHP’s RedisTransport and Go’s /redis transport implement the same reliable-queue convention on a plain Redis list, so either language can sit on either end — swap the PHP producer for Go, Python, Node, Java, or .NET, and the Go worker keeps reading. All six SDKs are 1.0 GA and emit byte-compatible envelopes, verified by a shared conformance suite.

That is the whole trick: stop serializing objects, start exchanging one JSON contract. The broker you already run carries it.

Read the wire contract for the field-by-field definition, then wire up your own producer with the PHP transports guide and the Go runtime and transports guide.