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_idthe 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
data—Café Widget ☕— round-trips byte-for-byte. The envelope is UTF-8 JSON; no escaping surprises, no mojibake. - The structured payload stays structured.
order_idis a number,currencyis a string, and the Go map reads them as such — becausedatawas 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.