Your Laravel app already dispatches jobs onto Redis or RabbitMQ. Here’s how to make some of those jobs readable by a Go, Python, Node, Java, or .NET service — over the same broker, with no serialize() on the wire, and without touching the standard Laravel jobs you already run.
BabelQueue adds a custom queue connection. Dispatch to it and the message goes out as a strict JSON envelope keyed by a URN; dispatch to any other connection and Laravel behaves exactly as before. The only thing that becomes polyglot is the connection you opt in.
Install
Pull in the Laravel driver and publish its config:
composer require babelqueue/laravel
php artisan vendor:publish --tag=babelqueue-config
That writes config/babelqueue.php, which holds the URN → handler map, the unknown-URN policy, and the dead-letter settings. You need PHP 8.2+, Laravel 11 or 12, and a running Redis or RabbitMQ broker.
Configure a polyglot connection
BabelQueue is a Laravel queue driver, so its connection lives in config/queue.php next to your existing ones. Add a babelqueue connection on the Redis transport:
// config/queue.php
'connections' => [
'babelqueue' => [
'driver' => 'babelqueue-redis',
'connection' => 'default',
'queue' => env('BABELQUEUE_QUEUE', 'default'),
'retry_after' => 90,
],
],
Prefer RabbitMQ? Swap the driver and point it at the broker:
'babelqueue' => [
'driver' => 'babelqueue-rabbitmq',
'host' => env('RABBITMQ_HOST', '127.0.0.1'),
'port' => env('RABBITMQ_PORT', 5672),
'queue' => env('BABELQUEUE_QUEUE', 'default'),
],
Your redis, sync, database, and sqs connections stay as they are. A job dispatched onto one of those is still a normal Laravel job, serialized the normal way. Only babelqueue is polyglot.
Map URNs to handlers
Consumers route on a URN — urn:babel:<context>:<event> — not a PHP class name. That’s what lets a non-PHP service consume the message: the identity on the wire is a stable string, not a class only PHP can resolve. Map each URN to the handler that processes it in config/babelqueue.php:
// config/babelqueue.php
return [
// Map each incoming URN to the handler that consumes it.
'handlers' => [
'urn:babel:orders:created' => App\Babel\Handlers\OrderCreatedHandler::class,
],
// What to do when an incoming URN has no registered handler:
// 'fail' | 'delete' | 'release' | 'dead_letter'
'on_unknown_urn' => 'dead_letter',
// Where exhausted or unroutable messages go. Failures from "orders"
// are republished to "orders.dlq" as the same envelope plus an
// additive `dead_letter` block, so any SDK can triage them.
'dead_letter' => [
'enabled' => true,
'suffix' => '.dlq',
],
];
on_unknown_urn decides the fate of a message whose URN you haven’t mapped. Routing it to dead_letter keeps it for triage instead of dropping it — useful while a polyglot fleet is still settling on its URNs.
Produce a message
Two ways to publish, both emitting the identical envelope. For a job with its own shape and behavior, implement ShouldQueuePolyglot: getBabelUrn() returns the URN, toPayload() returns the data body.
use BabelQueue\Contracts\ShouldQueuePolyglot;
final class OrderCreated implements ShouldQueuePolyglot
{
public function __construct(
private int $orderId,
private float $amount,
) {}
public function getBabelUrn(): string
{
return 'urn:babel:orders:created';
}
public function toPayload(): array
{
return [
'order_id' => $this->orderId,
'amount' => $this->amount,
];
}
}
dispatch(new OrderCreated(1042, 99.90));
For a one-off publish, the BabelQueue facade takes the URN and payload directly and returns the message id:
use BabelQueue\Facades\BabelQueue;
$id = BabelQueue::publish('urn:babel:orders:created', [
'order_id' => 1042,
'amount' => 99.90,
]);
Either path produces the canonical schema_version: 1 envelope. data is pure JSON, job is the URN, trace_id is stamped on publish, and meta carries the routing context:
{
"job": "urn:babel:orders:created",
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
"data": { "order_id": 1042, "amount": 99.90 },
"meta": { "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567", "queue": "default", "lang": "php", "schema_version": 1, "created_at": 1749132727000 },
"attempts": 0
}
This is the contract. A Go, Python, Node, Java, or .NET service reading the same queue parses these exact bytes — only meta.lang changes to name the consuming SDK. Nothing in here is PHP-specific, so nothing needs PHP to decode it.
Consume in Laravel
A PHP consumer maps the same URN to a handler. The handler’s handle() receives the decoded data, the meta block, and the trace_id for cross-service correlation. The optional failed() hook runs when processing throws:
namespace App\Babel\Handlers;
final class OrderCreatedHandler
{
public function handle(array $data, array $meta, string $traceId): void
{
logger()->withContext(['trace_id' => $traceId])
->info('Order received', $data);
// ... your business logic
}
public function failed(array $data, ?\Throwable $e): void
{
report($e);
}
}
$traceId is the same value the producer stamped, forwarded unchanged. Log it on every hop and one query reconstructs the whole path a job took, across every language that touched it.
Run the worker
BabelQueue is a drop-in driver, so you consume with the standard Laravel worker — point it at the polyglot connection:
php artisan queue:work babelqueue
Your other workers keep running against their own connections, untouched. Exhausted retries and unmapped URNs follow the on_unknown_urn and dead_letter policy from your config.
What you have now
One connection in your Laravel app speaks the canonical envelope. A job published there is consumable by all six SDKs — PHP, Python, Go, Node, Java, and .NET — at 1.0, over the Redis or RabbitMQ broker you already run. Everything outside that connection is the Laravel you already know.
To consume from another language, register the same URN in that SDK and point its worker at the same queue. Read consuming messages for the full PHP loop, and the wire contract for the byte-level shape every SDK agrees on.