Search by

babelqueue / laravel

muhammetsafak

Laravel adapter for BabelQueue: a drop-in polyglot queue driver. PHP produces strict JSON envelopes that Go, Python, Java, .NET and Node consumers read without PHP's serialize(). Built on babelqueue/php-sdk.

Package info

github.com/BabelQueue/laravel

Homepage

pkg:composer/babelqueue/laravel

Fund package maintenance!

muhammetsafak

Statistics

Installs: 11

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.6.0 2026-10-17 16:59 UTC

This package is auto-updated.

Last update: 2026-10-17 17:05:34 UTC


README

CI Packagist License: MIT

Polyglot Queues, Simplified. A drop-in Laravel queue driver that produces a strict, language-agnostic JSON envelope — so Go, Python, Java, .NET and Node.js services can consume your jobs without PHP's serialize().

Laravel's native queue serialises jobs with PHP serialize(), producing an object graph only PHP can read. BabelQueue replaces just the serialization layer with a frozen JSON envelope and URN-based routing, over the broker you already run (Redis or RabbitMQ). No sidecar, no proxy, no broker plugin.

This is the PHP/Laravel SDK. The full cross-language standard and the canonical wire contract are documented at babelqueue.com.

Requirements

  • PHP ^8.2
  • Laravel ^11.0 | ^12.0
  • Redis or RabbitMQ

Installation

composer require babelqueue/laravel
php artisan vendor:publish --tag=babelqueue-config

Add a polyglot connection to config/queue.php:

'connections' => [
    'bq-redis' => [
        'driver'      => 'babelqueue-redis',
        'connection'  => 'default',   // an illuminate/redis connection
        'queue'       => 'default',
        'retry_after' => 90,
    ],

    // RabbitMQ alternative
    'bq-rabbit' => [
        'driver'        => 'babelqueue-rabbitmq',
        'host'          => env('RABBITMQ_HOST', '127.0.0.1'),
        'port'          => env('RABBITMQ_PORT', 5672),
        'user'          => env('RABBITMQ_USER', 'guest'),
        'password'      => env('RABBITMQ_PASSWORD', 'guest'),
        'vhost'         => env('RABBITMQ_VHOST', '/'),
        'queue'         => 'default',
        'exchange'      => '',
        'exchange_type' => 'direct',
    ],

    // Apache ActiveMQ Artemis over STOMP (the §7 path; requires stomp-php/stomp-php).
    // Consumes messages produced by the Java (JMS) / .NET / Node / Python / Go Artemis
    // SDKs — Artemis bridges STOMP ↔ AMQP 1.0 ↔ JMS on the same address.
    'bq-artemis' => [
        'driver'             => 'babelqueue-artemis',
        'host'               => env('ARTEMIS_HOST', '127.0.0.1'),
        'port'               => env('ARTEMIS_STOMP_PORT', 61613),
        'username'           => env('ARTEMIS_USER', 'artemis'),
        'password'           => env('ARTEMIS_PASSWORD', 'artemis'),
        'queue'              => 'default',
        'destination_prefix' => '',   // e.g. '/queue/' if your Artemis needs the anycast prefix
        'read_timeout'       => 1,    // seconds a pop() waits before returning empty
    ],
],

The wire envelope

Every message is encoded as this frozen, schema_version: 1 envelope (full spec at babelqueue.com):

{
  "job": "urn:babel:orders:created",
  "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
  "data": { "order_id": 1042, "amount": 99.90 },
  "meta": { "id": "…", "queue": "default", "lang": "php", "schema_version": 1, "created_at": 1749132727000 },
  "attempts": 0
}
  • job — the message URN, never a class name. Convention: urn:babel:<context>:<event>.
  • trace_id — cross-service correlation id, preserved across every hop.
  • data — your pure-JSON payload.

Producing messages

Typed job (primary):

use BabelQueue\Contracts\ShouldQueuePolyglot;

final class CreateOrder 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];
    }
}

CreateOrder::dispatch(1042, 99.90)->onConnection('bq-redis');

Facade (sugar):

use BabelQueue\Facades\BabelQueue;

BabelQueue::publish('urn:babel:orders:created', ['order_id' => 1042, 'amount' => 99.90]);

Continuing a trace from a handler? Implement BabelQueue\Contracts\HasTraceId on the downstream job (or pass the traceId argument) and the inbound trace_id is forwarded instead of a new one being minted.

Consuming messages

Map URNs to handlers in config/babelqueue.php:

'handlers' => [
    'urn:babel:orders:created' => \App\Consumers\OnOrderCreated::class,
],
final class OnOrderCreated
{
    // $data, $meta, $traceId and $message are injected by name.
    public function handle(array $data, array $meta, string $traceId): void
    {
        logger()->info('order created', ['id' => $data['order_id'], 'trace' => $traceId]);
    }

    // Optional: called once when retries are exhausted.
    public function failed(array $data, ?\Throwable $e): void
    {
        report($e);
    }
}

Run a worker against the polyglot connection like any other:

php artisan queue:work bq-redis

Unknown URNs

config/babelqueue.php → on_unknown_urn: fail (default) · delete · release · dead_letter.

on_unknown_urn applies to messages whose URN has no mapped handler and that pass the schema-version check (a body that is not JSON, and so has an empty job, also lands here). The meta.schema_version check runs before URN routing and is independent of this setting: an envelope whose meta.schema_version is absent or not 1 is never handed to a handler and never reaches on_unknown_urn — even with delete, release or dead_letter. With the dead-letter queue enabled it is published to the configured DLQ (default <queue>.dlq) with reason poison and acked; otherwise an InvalidEnvelopeException (unsupported_schema_version) is thrown so the worker's retry / failed_jobs path records it.

Shared queues. A queue that also carries traffic this service does not own (for example a stock Laravel job pushed onto a babelqueue-* connection, or another team's schema_version: 2 messages) can no longer be silenced with on_unknown_urn: delete: such messages used to be dropped silently and now surface as poison DLQ entries or as failed jobs. Give this service its own queue or connection instead. (Before this change they were deleted without a trace.)

SQS: bq-schema-version message attribute

The babelqueue-sqs driver requests the message attributes on every receive (MessageAttributeNames = ['All']) and checks bq-schema-version before the body is decoded (broker-bindings §3.5 / §3.7). A missing or blank attribute (empty or ASCII whitespace only) falls through to the normal path, where meta.schema_version is checked as above. Any other value that is not exactly 1 is never decoded and never reaches a handler: it takes the same poison DLQ / InvalidEnvelopeException route as an unsupported meta.schema_version.

Dead-letter queue (cross-language)

Enable in config/babelqueue.php:

'dead_letter' => [
    'enabled' => true,
    'suffix'  => '.dlq',   // failures from "orders" go to "orders.dlq"
],

Permanently-failed (and, with on_unknown_urn => 'dead_letter', unroutable) messages are republished to the DLQ as the same envelope plus an additive dead_letter block (reason, error, failed_at, original_queue, attempts, lang). Because the DLQ is an ordinary queue, any SDK can triage it.

Idempotent consumption (opt-in)

Brokers deliver at least once: a redelivery after a missed ack — or a fan-out — can hand a consumer the same message twice. Enable idempotency to dedupe deliveries on the envelope's canonical per-message identity, meta.id, so a duplicate delivery is a no-op and the first delivery runs the handler exactly once. It is off by default and never changes the wire envelope.

Enable in config/babelqueue.php:

'idempotency' => [
    'enabled'    => true,
    'store'      => 'redis',   // 'redis' | 'database' | 'memory'
    'connection' => null,      // Redis/DB connection name (null = its default)
    'ttl'        => 3600,      // in-flight claim TTL (seconds) — crash backstop
],

The default backends are Laravel-native and reuse infrastructure you already run:

  • redis — over a Laravel Redis (predis) connection.
  • database — over a Laravel DB connection (PostgreSQL / MySQL / SQLite). Create the table once at deploy time:
    DB::connection(config('babelqueue.idempotency.connection'))
        ->getPdo()
        ->exec(\BabelQueue\Idempotency\PdoStore::ddl(config('babelqueue.idempotency.table')));
  • memory — single-process only (tests / a lone worker); not shared, not persistent.

redis and database are claiming stores: when two workers receive the same meta.id concurrently, exactly one runs the handler and the other parks for redelivery — closing the in-flight window, not just deduping after success.

It binds the php-sdk IdempotencyStore in the container, so you can rebind it to a custom store:

$this->app->instance(\BabelQueue\Idempotency\IdempotencyStore::class, new MyStore());

A thrown handler leaves the id unmarked, so retry / DLQ still apply and a later delivery re-runs it. A message with no usable meta.id always runs (fail-open).

Known limits

Redis retry (babelqueue-redis). pop() delegates to Laravel's RedisQueue::pop, whose atomic reserve/migrate step is a Lua script that decodes and re-encodes the body with Redis' bundled cjson. On every retry the body that goes back to Redis (and, via :delayed, to polyglot consumers) is therefore rewritten by cjson, not by BabelQueue:

  • integers with more than about 14 significant digits (order or payment IDs, int64 values) lose precision;
  • an empty [] is re-encoded as {};
  • an inbound urn alias is not moved into job, and the keys that message-envelope.md section 10 forbids (timestamp, meta.ts, meta.max_retries, meta.attempts, meta.source) are not removed.

This deviates from ADR-0031 (b)/(c), which ask for a re-emit that is byte-safe and strips forbidden keys. It predates 1.23 and is not fixed here, because it needs a BabelQueue-owned pop/release Lua script (a design decision, tracked as a follow-up). The RabbitMQ, Artemis and SQS drivers are not affected: RabbitMQ and Artemis republish the original body (only the alias and the forbidden keys are edited), SQS never rewrites it. Until then, keep identifiers longer than 14 digits as strings in data when a job can be retried on Redis.

Testing

composer install
vendor/bin/phpunit

Links

License

MIT © Muhammet Şafak. See LICENSE.