Messaging¶
firefly/messaging is LaraFly's lower-level raw-bytes broker layer (a pyfly messaging analog): a
MessageBrokerPort moving opaque byte payloads by topic, with consumer-group semantics, an in-memory
default adapter plus a Laravel-queue async adapter (and Kafka and RabbitMQ listener
containers from the eda broker packages), and a broker-agnostic #[MessageListener] attribute
carrying its own per-listener retry/DLQ policy. There is no envelope, no event-type pattern, and no
serializer here — see Event-Driven Architecture for that higher-level surface.
The MessageBrokerPort port¶
interface MessageBrokerPort
{
// …
public function publish(string $topic, string $value, ?string $key = null, array $headers = []): void;
public function subscribe(string $topic, callable $handler, ?string $group = null): void;
public function start(): void;
public function stop(): void;
}
A handler is a plain callable(Message): void. $value is a raw byte string — nothing is parsed,
deserialized, or pattern-matched by this package; $topic is matched exactly, and $group opts a
subscriber into consumer-group round-robin (see below).
The Message value object¶
final readonly class Message
{
// …
public function __construct(
public string $topic,
public string $value,
public ?string $key = null,
public array $headers = [],
) {}
// …
public function withHeaders(array $extra): self
{
return new self($this->topic, $this->value, $this->key, array_merge($this->headers, $extra));
}
}
$value is raw bytes — nothing here parses or deserialises it.
Lower-level than eda's EventEnvelope: no eventType, no eventId/timestamp, no serializer round-trip —
just a topic, bytes, an optional partition/routing key, and string headers. Immutable;
withHeaders(array $extra): self returns a copy (used to re-key a dead-lettered message).
Adapters¶
InMemoryMessageBroker — the default¶
Requires start() before publish() — publishing to a broker that hasn't started throws
MessagingException. Delivery rules, verified against the shipped test suite:
- A subscriber with no group receives every message published to its topic (broadcast) — each groupless subscriber is delivered to independently; two groupless subscribers both see every message, they never split a shared rotation.
- Subscribers sharing a named group receive messages round-robin — one member per publish, cycling through the group's members in subscription order.
- Each distinct group is its own delivery target. Two different groups on the same topic each receive every message (their own round-robin copy); groups don't compete with each other, only members within a group do.
stop() flips started back to false, so publish() fails again until start() is called again. Zero
external services — this is the skeleton default (firefly.messaging.provider unset or memory).
QueueMessageBroker — the async adapter¶
Same enqueue-then-worker-deliver shape as eda's QueueEventBus, over illuminate/queue + illuminate/bus:
publish()wraps(topic, value, key, headers)in oneDispatchMessageJob implements ShouldQueue, dispatches it onto the configured connection/queue, and returns immediately.- The worker's
DispatchMessageJob::handle()resolves the process-localMessageBrokerPortsingleton — whose subscriptions were populated during that worker's own boot byMessageListenerWiringPass— and callsdeliver(), invoking every subscriber registered for that topic. - The
Dispatcheris resolved fresh from the container on everypublish()call, exactly likeQueueEventBus, soBus::fake()still intercepts it.
The queue path drops consumer-group round-robin
QueueMessageBroker::subscribe() accepts a $group argument (for interface parity) but ignores it —
every subscriber on a topic is invoked on every delivered message, unconditionally. Genuine
competing-consumer semantics (one message, one group member) require a real broker's own group
coordination, which the Kafka and RabbitMQ providers have (see Kafka and RabbitMQ).
Consumer-group round-robin is an in-memory-only nicety on the other adapters — do not rely on it once
firefly.messaging.provider=queue.
#[MessageListener] → scanner → manifest → wiring pass¶
#[Attribute(Attribute::TARGET_METHOD)]
final class MessageListener
{
public function __construct(
public string $topic,
public ?string $group = null,
public int $retries = 0,
public float $retryDelay = 0.0,
public ?string $deadLetterTopic = null,
) {}
}
#[MessageListener] is the one broker-agnostic attribute for all brokers — the @KafkaListener/
@RabbitListener equivalent, but there is deliberately no broker-specific variant (a locked design
decision). It is inert metadata only.
MessageListenerScanner(packages/messaging/src/Scanner/MessageListenerScanner.php) is the sole reflection site in the package (grep-enforced byReflectionFreeMessagingTest). It walks the app's PSR-4 roots, reflects every public method carrying#[MessageListener], and emits one pure-arrayMessageListenerDescriptor(class,method,topic,group,retries,retryDelay,deadLetterTopic) per annotation.MessageListenerManifestloads thevar_exported descriptor array back viarequire+map, reflection-free — mirroring eda'sEventListenerManifest.MessageListenerWiringPassruns atBootPhase::WiringPasses(1000), in every process. For each manifest row it wraps the target invocation inRetryingMessageHandlerusing that descriptor's ownretries/retryDelay/deadLetterTopic(not a global config setting — contrast eda, below), calls$broker->subscribe($descriptor->topic, $wrapped, $descriptor->group), and finally calls$broker->start()once every listener is subscribed — so the in-memory broker is publish-ready the moment boot completes (a no-op on the queue adapter). As with eda, the invoking closure resolves the target bean fresh from the container on every dispatch, never caching it at registration.
Retry / DLQ — per listener, not config-driven¶
This is the one deliberate contrast with eda: eda's retry policy is one
config-driven setting applied to every #[EventListener]; messaging's is carried on each
#[MessageListener] attribute individually — different consumers on different topics can have entirely
different retry counts, delays, and dead-letter destinations.
RetryingMessageHandler::wrap(callable $handler, int $retries, float $retryDelay, ?string $deadLetterTopic, DeadLetterStore $dlq): Closure
always wraps the handler (no unwrapped fast path):
- Invoke the handler.
- On throw, retry up to
$retriesmore times with linear backoff (attempt N sleepsretryDelay * Nseconds). - On final failure: if
$deadLetterTopicis set, store a newMessagere-keyed to that topic — samevalue/key, headers merged withx-original-topic(the message's original topic) andx-exception(the exception message) — into the boundDeadLetterStore, and return (nothing escapes); otherwise re-throw the original exception unchanged.
interface DeadLetterStore
{
public function store(Message $message, Throwable $cause): void;
/**
* @return list<DeadLetterEntry>
*/
public function all(): array;
}
This is a separate class from eda's DeadLetterStore (Firefly\Messaging\DeadLetter\DeadLetterStore vs
Firefly\Eda\DeadLetter\DeadLetterStore) — no shared code, no cross-package edge; it wraps a raw Message
rather than an EventEnvelope, faithful to the two ports' different payload shapes.
firefly.messaging.dead-letter.store selects it: memory (default) or database (DatabaseDeadLetterStore, one
firefly_messaging_dead_letters row per message on firefly.messaging.dead-letter.connection, the raw bytes
base64-encoded so they round-trip on every driver; run php artisan migrate).
php artisan firefly:messaging:dead-letters:replay republishes entries to their original topic (--id, --topic,
--limit, --dry-run, --keep).
Config keys¶
| Key | Type | Default | Meaning |
|---|---|---|---|
firefly.messaging.provider |
memory|queue|kafka|rabbitmq |
memory |
Selects the broker: InMemoryMessageBroker or QueueMessageBroker (MessagingAutoConfiguration); kafka/rabbitmq are bound by the eda adapters (see Kafka and RabbitMQ). |
firefly.messaging.queue.connection |
string|null | null (default connection) |
Queue connection QueueMessageBroker/DispatchMessageJob dispatch onto, when provider=queue. |
firefly.messaging.queue.name |
string|null | null (default queue) |
Queue name, when provider=queue. |
firefly.messaging.dead-letter.store |
memory|database |
memory |
Where a #[MessageListener(deadLetterTopic: …)] message goes once its retries are exhausted — see above. |
firefly.messaging.dead-letter.connection |
string|null | null |
The connection DatabaseDeadLetterStore writes on (null = the default connection). |
firefly.messaging.listener.retry.max-attempts |
int | 5 |
Attempts per delivery (the first included) on a listener container broker. |
firefly.messaging.listener.retry.initial-delay |
duration | 1s |
Exponential back-off after the first failure (seconds, or 250ms/30s/5m/1h). |
firefly.messaging.listener.retry.multiplier |
float >= 1 | 2.0 |
Back-off multiplier after each further failure. |
firefly.messaging.listener.retry.max-delay |
duration | 30s |
Back-off cap. |
firefly.messaging.listener.dead-letter.enabled |
bool | true |
Dead-letter after the last attempt; false logs and skips. |
firefly.messaging.kafka.brokers |
string | firefly.eda.kafka.brokers, else 127.0.0.1:9092 |
Kafka bootstrap list. |
firefly.messaging.kafka.group-id |
string | firefly |
Group of a listener that names none. |
firefly.messaging.kafka.dlt-suffix |
string | .DLT |
Dead-letter topic suffix. |
firefly.messaging.rabbitmq.exchange |
string | firefly.messages |
Durable direct exchange. |
firefly.messaging.rabbitmq.dead-letter-exchange |
string | '' (= <exchange>.dlx) |
Dead-letter exchange. |
firefly.messaging.rabbitmq.prefetch |
int | 10 |
basic.qos prefetch. |
There is no firefly.messaging.retries/retry_delay config key — on the memory and queue adapters
retry/DLQ policy lives entirely on each #[MessageListener] attribute (see above), not in configuration. The
listener.* keys apply only to a listener container broker — the Kafka and RabbitMQ providers — described next.
Listener container brokers and firefly:messaging:consume¶
A broker that consumes from a real server (the Kafka and RabbitMQ adapters) implements
Firefly\Messaging\Container\ListenerContainerBroker, a MessageBrokerPort with two more methods:
subscribeListener(string $topic, callable $handler, ?string $group, ListenerOptions $options) and
poll(int $timeoutMs): bool. Its container owns retries and dead letters and acknowledges a delivery only once
its listeners have handled it or it has been dead-lettered, so MessageListenerWiringPass hands it the BARE
listener plus the listener's ListenerOptions instead of wrapping it in RetryingMessageHandler — wrapping it
too would retry every failure twice over.
Each delivery runs under a RetryPolicy through the shared ListenerInvoker: firefly.messaging.listener.retry.*
(five attempts, 1s doubling, capped at 30s by default — PyFly's listener container), unless the listener sets
retries: N > 0, which means N + 1 attempts with retryDelay as a linear back-off, exactly as on the in-memory
and queue adapters. A listener's deadLetterTopic replaces the broker's default dead-letter route. A
dead-lettered message carries x-original-topic, x-exception (the short exception class name),
x-exception-message and x-delivery-attempts (DeadLetterHeaders).
php artisan firefly:messaging:consume {--max-messages=} {--time-limit=} {--poll-timeout=1000} {--sleep=0} is
the worker: it polls one delivery at a time until a bound trips or a SIGINT/SIGTERM arrives (honored between
deliveries when ext-pcntl is loaded), then stops the broker. On memory (delivers in process) and queue (runs
in php artisan queue:work) it refuses with exit code 1 and the reason. Setting
firefly.messaging.provider=kafka|rabbitmq without the matching adapter bound refuses the boot instead of
falling back to the in-memory broker.
MessagingAutoConfiguration (#[Configuration] #[Order(1000)]) binds the MessageBrokerPort and
DeadLetterStore beans, each behind #[ConditionalOnMissingBean] so an application binding its own wins. It
is always-on — installing firefly/messaging requires no configuration to boot with working (in-memory)
defaults.
Kafka and RabbitMQ¶
firefly.messaging.provider=kafka (from firefly/eda-kafka, needs ext-rdkafka) and =rabbitmq (from
firefly/eda-rabbitmq) bind a ListenerContainerBroker: the same #[MessageListener]s, consumed by
php artisan firefly:messaging:consume (--max-messages, --time-limit, --poll-timeout, --sleep).
- Retries belong to the container. A delivery is attempted
firefly.messaging.listener.retry.max-attemptstimes (default 5) with an exponential back-off (1s, ×2, capped at 30s); a listener withretries: Ngets N + 1 attempts with itsretryDelayas a linear back-off instead. On a container,retries: 0means "the default". - Then a dead letter. Kafka produces the record to the listener's
deadLetterTopicor<topic>.DLT; RabbitMQ republishes it to the listener'sdeadLetterTopic, or rejects it so the queue's dead-letter exchange (<exchange>.dlx) moves it to<queue>.dlq. Headers:x-original-topic,x-exception(short class),x-exception-message,x-delivery-attempts(Kafka addsx-dlt-reason,x-dlt-source-topic,x-dlt-source-offset).firefly.messaging.listener.dead-letter.enabled=falselogs and skips instead. - At least once. A record is committed / a message acknowledged only after its listeners handled it or it was dead-lettered; a dead letter that cannot be written leaves it uncommitted and stops the worker.
- Groups are real. Kafka runs one consumer per group (a listener without one joins
firefly.messaging.kafka.group-id); RabbitMQ gives each group its own durable queue (firefly.<topic>without one). The RabbitMQ connection is the event bus's — thefirefly.eda.rabbitmq.*host, port, user, password and vhost.
Example¶
The package's own consumer fixture, which the wiring test subscribes and delivers to:
use Firefly\Container\Attributes\Component;
use Firefly\Messaging\Attributes\MessageListener;
use Firefly\Messaging\Message;
// …
#[Component]
final class OrderConsumer
{
public function __construct(private readonly ListenerSpy $spy) {}
#[MessageListener(topic: 'orders')]
public function consume(Message $message): void
{
$this->spy->record($message->topic);
}
}
Add retries: 3, deadLetterTopic: 'orders.DLT' to that attribute and the same listener retries three times and
dead-letters what still fails. Publishing is the other half, and needs nothing but the port:
use Firefly\Messaging\MessageBrokerPort;
final class OrderPublisher
{
public function __construct(private readonly MessageBrokerPort $broker) {}
public function send(string $payload): void
{
$this->broker->publish('orders', $payload);
}
}
Sibling of firefly/eda — no shared code¶
firefly/eda and firefly/messaging are independent sibling leaf packages: neither depends on, nor is
depended on by, the other — no Deptrac edge exists in either direction. This is a deliberate design call, not
an oversight:
- Parity. pyfly keeps
edaandmessagingas two separate modules with two separate ports, two separate listener decorators, and two separate retry/DLQ implementations. Faithfully mirroring that keeps both port surfaces true to their source. - Layering direction. Messaging is lower-level (raw bytes) than eda (envelopes). The only non-inverting edge would be eda depending on messaging — but the in-memory eda default would then pay for a pointless in-process byte round-trip through a message broker on its default path, and pyfly's own eda adapters talk to brokers directly rather than through its messaging module.
- Cost of independence is small. Each retry/DLQ helper is genuinely different (eda wraps an
EventEnvelope; messaging wraps aMessage), so each package owning its own is faithful, not duplicated boilerplate. Thex-original-topic/x-exceptionheader convention is shared by documentation, not by a shared class.
Known-latent¶
These are carried-forward, documented limitations of the M9 shipment — not bugs:
- The queue path drops consumer-group round-robin (see the warning above) —
QueueMessageBrokerignores$groupentirely and broadcasts to every subscriber on a topic. Genuine competing-consumer semantics need a real broker's own group coordination, which the Kafka and RabbitMQ providers have. - "Async" is queue-backed, not coroutine-based — under the
syncqueue driver (or with no worker running),QueueMessageBrokerdelivers synchronously (minus the group-drop above), same async→sync contract as the rest of LaraFly. - A consumer outside
firefly.scan.pathsnever subscribes. TheMessageListenerManifestresolves to thefirefly:cacheartifact if present, otherwise an in-process scan offirefly.scan.paths, otherwise empty — no hand-wiring needed, but a listener the scan cannot see is silently absent rather than an error. Same shape asfirefly/eda's listeners. firefly/messagingandfirefly/edaare independent sibling packages with no dependency in either direction — see Sibling offirefly/edaabove.