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 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, // raw bytes
public ?string $key = null,
public array $headers = [],
) {}
}
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 only lands with a broker adapter at SP-4. Consumer-group round-robin is an
in-memory-only nicety until then — 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.
Config keys¶
| Key | Type | Default | Meaning |
|---|---|---|---|
firefly.messaging.provider |
memory|queue |
memory |
Selects InMemoryMessageBroker or QueueMessageBroker (MessagingAutoConfiguration). |
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. |
There is no firefly.messaging.retries/retry_delay config key — retry/DLQ policy lives entirely on each
#[MessageListener] attribute (see above), not in configuration.
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.
Example¶
use Firefly\Container\Attributes\Component;
use Firefly\Messaging\Attributes\MessageListener;
use Firefly\Messaging\Message;
use Firefly\Messaging\MessageBrokerPort;
#[Component]
final class OrderConsumer
{
#[MessageListener(topic: 'orders', retries: 3, deadLetterTopic: 'orders.DLT')]
public function consume(Message $message): void
{
// $message->value is the raw byte payload published to "orders".
}
}
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:
- Real brokers are OUT. Only the in-memory and Laravel-queue adapters ship. Kafka/RabbitMQ land as their
own
firefly/messaging-<broker>packages at SP-4, mirroring eda's broker-package plan. - 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, arriving with the SP-4 broker adapters. - "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.