Consuming messages with the PHP core
Last updated:
The core gives you decode + validation + URN routing, and — for the brokers PHP can
consume framework-less (Kafka and Pulsar) — ready-made consume() loops plus a
consume runtime. The loop that pulls bytes off a broker without a
core consumer is yours (or your framework adapter’s).
Decode
use BabelQueue\Codec\EnvelopeCodec;
$envelope = EnvelopeCodec::decode($rawBody); // plain PHP array
Decode warnings (forbidden keys)
decode() is tolerant, as the wire contract requires: a message that carries a
forbidden key (timestamp, meta.max_retries, meta.attempts, meta.source, meta.ts) still decodes,
the key is dropped from the returned array (so a re-encode never carries it), and a
warning naming its JSON pointer is reported. A data value that is a non-empty JSON array
(a list, not an object) is rejected instead: the result is [], like malformed JSON, with a
warning for /data.
By default the warning goes to error_log(). Pass a per-call sink as the second argument
(fn (string $message, string $pointer)), or install a process-wide one — for example a
PSR-3 logger:
EnvelopeCodec::setWarningHandler(
fn (string $message, string $pointer) => $logger->warning($message, ['pointer' => $pointer]),
);
$envelope = EnvelopeCodec::decode($rawBody, fn (string $message, string $pointer) => /* per call */ null);
EnvelopeCodec::setWarningHandler(null) restores the error_log() fallback. The keys
themselves are listed in EnvelopeCodec::FORBIDDEN_TOP_LEVEL_KEYS and
EnvelopeCodec::FORBIDDEN_META_KEYS.
Validate before you dispatch
EnvelopeValidator::check() returns a reason (or null if valid), so you can
quarantine a message you don’t understand instead of silently dropping it:
use BabelQueue\Validation\EnvelopeValidator;
if ($reason = EnvelopeValidator::check($envelope)) {
// e.g. EnvelopeValidator::REASON_UNSUPPORTED_SCHEMA_VERSION
// → dead-letter / quarantine, don't drop.
return;
}
Reasons include REASON_MISSING_URN, REASON_MISSING_META,
REASON_UNSUPPORTED_SCHEMA_VERSION, REASON_INVALID_DATA,
REASON_MISSING_TRACE_ID, REASON_INVALID_ATTEMPTS. There is also
EnvelopeValidator::isValid($envelope): bool and validate($envelope): void (which
throws InvalidEnvelopeException carrying the reason + envelope).
EnvelopeCodec::accepts($envelope)is a quick boolean check for the same consumer-side rules when you don’t need the reason.
For a full structural check against the bundled canonical JSON Schema — every field,
type and constraint, not just the consumer-side rules — use the offline SchemaValidator:
use BabelQueue\Validation\SchemaValidator;
SchemaValidator::isValid($envelope); // bool
$reason = SchemaValidator::check($envelope); // "<json-pointer>: <reason>" | null
SchemaValidator::validate($envelope); // throws on the first violation
It’s offline and dependency-free (the schema ships in the package), so it’s also handy in tests and CI to assert an envelope you produce is conformant.
Route by URN
$urn = EnvelopeCodec::urn($envelope); // reads `job`, accepting `urn` as an alias
match ($urn) {
'urn:babel:orders:created' => $handler->handle($envelope['data'], $envelope['meta']),
default => /* apply your unknown-URN strategy */ null,
};
For unmapped URNs, the BabelQueue\Routing\UnknownUrnStrategy constants
(FAIL / DELETE / RELEASE / DEAD_LETTER) name the standard choices; see
error handling in the wire contract.
Framework-less consumers (Kafka & Pulsar)
For Redis, RabbitMQ, SQS and Artemis, the broker loop is your framework worker’s (the
Laravel drop-in drivers, Symfony Messenger) — the core stays a codec. For Kafka and
Pulsar, the core ships complete framework-less consumers with receive / ack /
release primitives and a consume() loop:
KafkaConsumer(§6, overext-rdkafka) — process-then-commit (at-least-once):receive()polls and decodes a record withattemptsreconciled (thebq-attemptsheader wins, else the body),commit()advances the offset, andconsume($handler, $shouldStop)runs the loop, committing only on a clean return.PulsarConsumer(§5, over Pulsar’s WebSocket API) —receive(),acknowledge()(the §5 “delete”),release()(negativeAcknowledge), and aconsume()loop.attemptsis reconciled tomax(body.attempts, redeliveryCount). A failing handler is retried by re-publishing the message to the same topic withbq-attempts + 1and acknowledging the original — not by a negative acknowledgement, which does not advance Pulsar’s redelivery count onExclusive/Failoversubscriptions. See Pulsar consumer: retry and the DLQ below.
Both decouple from their broker client behind a small client seam (KafkaConsumerClient /
PulsarWebSocketConsumerClient) that you can fake in tests, so the consumer is
dependency-free and testable:
use BabelQueue\Transport\PulsarConsumer;
$consumer = new PulsarConsumer($pulsarWebSocketConsumerClient);
$consumer->consume(function ($message) {
// $message->getUrn(), $message->getData(), $message->attempts()
// return → acknowledge; throw → retry (re-publish with bq-attempts + 1)
}, fn () => $shouldStop);
The consume runtime
consume() takes any callable. Pass a Consume\Dispatcher and you get URN → handler
routing, the four on_unknown_urn strategies, and a max-attempts cap with dead-lettering —
composed from pieces the core already ships:
use BabelQueue\Consume\Dispatcher;
use BabelQueue\Consume\DeadLetterPublisher;
use BabelQueue\Contracts\ConsumedMessage;
use BabelQueue\Routing\UnknownUrnStrategy;
$dispatch = (new Dispatcher(
onUnknownUrn: UnknownUrnStrategy::DEAD_LETTER,
maxAttempts: 3, // the default: 3 deliveries in total
deadLetters: new DeadLetterPublisher($pulsarProducer), // failed / unknown-URN / poison records go to <queue>.dlq
))->on('urn:babel:orders:created', fn (ConsumedMessage $m) => handle($m->getData(), $m->getTraceId()));
$consumer->consume($dispatch, fn () => $shouldStop);
A handler that returns acks the message; one that throws is retried (at-least-once). On an
unknown URN the strategy applies — delete drops it, dead_letter routes it to <queue>.dlq,
and fail / release throw to retry. The retry is bounded by maxAttempts, the total
number of deliveries a message gets, the first included. The default is 3; 0 is still
accepted and means no cap. The same cap bounds a failing handler and fail / release of an
unknown URN.
- With a
DeadLetterPublisher, a message that reaches the cap is written to<queue>.dlq(reasonfailedorunknown_urn,dead_letter.attempts= the counted value, which is3with the default). The queue is taken from the consumer’s own topic / queue, not from the envelope’smeta.queue, anddefault.dlqis never written. When the consumer’s queue is unknown the message cannot be addressed: it is acknowledged, dropped and logged. - Without a publisher, a message that reaches the cap is acknowledged, dropped and logged
on Pulsar (the
$onDropargument replaces the defaulterror_log, and the body is never logged).on_unknown_urn: dead_letterwithout a publisher degrades todeletethe same way, and the drop is now logged instead of silent. - Poison never reaches a handler. A body that does not decode into an envelope, or has
neither a valid
jobnor a validurn, is not an unknown URN. With a publisher it is written to<queue>.dlqat once, without retry, as a poison record (reason: "poison", the original body kept);deletedrops it; with no publisherfail/releasetake the bounded path above anddead_letterdrops it. - A version-gated Pulsar message is settled on its first delivery. A delivery whose
bq-schema-versionproperty is present, not blank and not"1"never reaches a handler, an unknown-URN decision or a retry, whateverbq-attempts, the redelivery count ormaxAttemptssay. With noDeadLetterPublisher(or a queue that cannot be named) it is acknowledged, dropped and logged through$onDrop.deletedrops it too, and thedead_letterstrategy degrades todeletethe same way. Otherwise the raw body (byte for byte, with nodead_letterblock and no poison wrapping; the incoming routing properties ride on it exceptbq-attemptsandbq-schema-version) is written to<queue>.dlqwithDeadLetterPublisher::publishRaw()and the delivery is acknowledged.failandreleasebehave alike here: a version-gated delivery is never released. A failed write throwsConsume\DeadLetterWriteFailed, soPulsarConsumer::consume()negatively acknowledges it: nothing is acknowledged or retried, the gate rejects the redelivery again, and the write is tried again. The gate reads only the strategy you configured. Kafka is out of scope: a version-gated Kafka record keeps the unknown-URN path. - On Kafka the
Dispatchernever drops. OverKafkaConsumer, a message that reaches the cap without a publisher is rethrown, so aKafkaRetryRouter(or the consumer’s own rules) takes the terminal decision. With a publisher it dead-letters to<topic>.dlq,<topic>being the record’s source topic (else the topic the consumer is subscribed to; when that is ambiguous the record is committed, dropped and logged). Because the default cap is now 3, aDispatcherwith a publisher dead-letters before a router with its default of 5: passmaxAttempts: 0to leave the cap to the router.
Behaviour change. The default
maxAttemptsmoved from0(no cap) to3. A deployment that relied on no cap must now passmaxAttempts: 0explicitly.Behaviour change (ADR-0037, since 1.27.0). A version-gated Pulsar message used to take the unknown-URN path, so under
fail/releaseit was retried through the bounded re-publish before the terminal decision, and its record was the{"raw": ...}envelope with adead_letterblock (reason: "unknown_urn"). It is now settled on its first delivery and written as the raw body, as the Pulsar binding describes. A reader of<queue>.dlqrecognises that record by the absence of adead_letterblock.
Pulsar consumer: retry and the DLQ
PulsarConsumer and the Dispatcher bound every message the same way on every subscription
type: a retry re-publishes the message to the same topic with bq-attempts + 1 (the
envelope’s attempts kept in sync) and acknowledges the original. A failed re-publish
negatively acknowledges the original instead, and a failed dead-letter write does too
(Consume\DeadLetterWriteFailed), so nothing is lost.
The consumer needs a producer for that re-publish, and it derives one from your client; a
value you pass wins. The SDK’s own PulsarWebSocketSubscription supplies everything, so this
is the whole setup, and the cap holds on Exclusive and Failover subscriptions:
use BabelQueue\Transport\PulsarConsumer;
use BabelQueue\Transport\PulsarWebSocketSubscription;
$consumer = new PulsarConsumer(
new PulsarWebSocketSubscription('http://pulsar:8080', 'persistent://public/default/orders', 'my-sub'),
);
Your own PulsarWebSocketConsumerClient adapter works too: implement PulsarServiceEndpoint
(or pass serviceUrl:, plus serviceAuthToken: on an authenticated cluster) or retryProducer:
for the producer, and PulsarConsumerDescriptor (or queue: with tenant: and namespace:) so
the consumer knows its full topic.
The constructor fails closed. If no producer can be derived, it throws a
BabelQueueException unless the subscription is known to be Shared or Key_Shared, where a
negative acknowledgement does advance the redelivery count. An unknown subscription type counts
as Exclusive, Pulsar’s own default. To keep a nack-only consumer, declare it with
subscriptionType: 'Shared'; otherwise give it a producer.
Kafka retry topics
Kafka has no native per-message retry or delay, so the core implements §6.4/§6.5 with the
tiered retry-topic pattern: KafkaRetryRouter::route() sends a failed record to
<topic>.retry.<n> (n is the 1-based tier index in ascending-delay order, e.g.
orders.retry.1; with bq-attempts + 1) or, past the cap, to <topic>.dlq; a
KafkaRetryConsumer then waits the tier delay and re-injects it into the work topic. See
the Kafka binding.
Cross-language
Because you decode the canonical envelope, the message may have been produced by any BabelQueue SDK — a Go service, a Python worker, a Node app — and the URN is the only shared contract. See the cross-language example.