DEV Community

Cover image for Symfony Messenger Transports: Building a Transactional Outbox on Top
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Messenger Transports: Building a Transactional Outbox on Top


You save an order. The row commits. Then you dispatch OrderPlaced to RabbitMQ so billing, search indexing, and the welcome email all fire. RabbitMQ was mid-restart for four seconds. The dispatch throws. Your order is in the database and no downstream system will ever hear about it.

Flip the failure. The dispatch succeeds, then your web process gets OOM-killed before the Doctrine transaction commits. Now billing has an event for an order that does not exist. Support gets a chargeback for a ghost.

This is the dual-write problem, and no amount of retry logic on the dispatch call fixes it. You are writing to two systems that do not share a transaction. The fix is a transactional outbox, and Symfony Messenger already ships most of the parts. You just have to wire them in the right order.

The dual write, precisely

Here is the handler almost everyone writes first.

final class PlaceOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
        private MessageBusInterface $bus,
    ) {}

    public function __invoke(PlaceOrder $command): void
    {
        $order = Order::place($command->id, $command->items);
        $this->orders->save($order);              // write #1
        $this->bus->dispatch(
            new OrderPlaced($order->id()),        // write #2
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two writes, two systems, one hope that both survive. If save() commits and dispatch() throws, the event is lost. If dispatch() sends and the surrounding transaction rolls back, you published an event for state that never existed. There is no ordering of these two lines that closes both gaps at once.

DispatchAfterCurrentBus: half a solution

Messenger has a middleware for the second half of the problem. DispatchAfterCurrentBusStamp tells the bus to hold a message until the current message finishes handling, and the doctrine_transaction middleware wraps that handling in a transaction.

use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentBusStamp;

$this->bus->dispatch(new Envelope(
    new OrderPlaced($order->id()),
    [new DispatchAfterCurrentBusStamp()],
));
Enter fullscreen mode Exit fullscreen mode
# config/packages/messenger.yaml
framework:
    messenger:
        buses:
            command.bus:
                middleware:
                    - doctrine_transaction
Enter fullscreen mode Exit fullscreen mode

The dispatch_after_current_bus middleware is part of the default stack, so it already runs ahead of your doctrine_transaction entry. The effect: the event is only really sent after the handler returns and the transaction commits. If the transaction rolls back, the event is never sent. That kills the second failure, the ghost order.

It does not kill the first. Commit happens, then the broker send happens as a separate step. If the process dies in that gap, or RabbitMQ is down, the event is gone and nothing will replay it. You moved the window, you did not close it. For a real outbox, the record of "this event must be delivered" has to land in the same transaction as the order.

The Doctrine transport is already an outbox

The insight most teams miss: Messenger's Doctrine transport is a table. When you route a message to it, send() runs a plain INSERT on the DBAL connection. If a transaction is open on that connection, the insert joins it.

So route the event to a Doctrine transport and dispatch it without the after-current-bus stamp. You want the insert inside the transaction, not deferred past the commit.

framework:
    messenger:
        buses:
            command.bus:
                middleware:
                    - doctrine_transaction
        transports:
            outbox:
                dsn: 'doctrine://default?table_name=outbox_messages'
        routing:
            'App\Event\OrderPlaced': outbox
Enter fullscreen mode Exit fullscreen mode

Now trace the handler again. doctrine_transaction opens the transaction. save() inserts the order row. dispatch(new OrderPlaced(...)) hits SendMessageMiddleware, which inserts a row into outbox_messages on the same connection. The handler returns, the transaction commits. Order and outbox row commit together or not at all. That is the atomic write the naive version could not give you.

public function __invoke(PlaceOrder $command): void
{
    $order = Order::place($command->id, $command->items);
    $this->orders->save($order);
    $this->bus->dispatch(new OrderPlaced($order->id()));
}
Enter fullscreen mode Exit fullscreen mode

No stamp, no manual transaction juggling. The message never reaches a broker during the request. It sits in a SQL table, durable, waiting for a relay.

The relay is just a consumer

The relay is the process that drains the outbox and does the real work. With the Doctrine transport, that process already exists: it is messenger:consume.

php bin/console messenger:consume outbox \
    --time-limit=3600 \
    --memory-limit=128M
Enter fullscreen mode Exit fullscreen mode

Under the hood the transport pulls rows with SELECT ... FOR UPDATE SKIP LOCKED, hands each message to its handler, and deletes the row on success. SKIP LOCKED is what lets you run several relay workers without two of them grabbing the same row. If a handler throws, the retry strategy on the transport takes over, and after the last attempt the message goes to your failure_transport.

If the downstream work is talking to RabbitMQ, the handler forwards there:

#[AsMessageHandler(fromTransport: 'outbox')]
final class RelayOrderPlaced
{
    public function __construct(
        private MessageBusInterface $bus,
    ) {}

    public function __invoke(OrderPlaced $event): void
    {
        $this->bus->dispatch($event, [
            new TransportNamesStamp('async_rabbit'),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Often you do not even need the forward. If the consumers of the event live in the same app, the outbox transport is the queue, and the relay is your normal worker. Fewer moving parts, same delivery guarantee.

Ordering is weaker than you think

The Doctrine transport reads oldest-first, ordered by available_at then id. With one worker, that is roughly the order events were written. The moment you run two relay workers, SKIP LOCKED lets worker B grab row 11 while worker A is still processing row 10. Across workers, order is not guaranteed.

For most events that is fine. Billing does not care whether order 500 or 501 was relayed first. When you do need per-entity order, do not try to serialize the whole transport. Partition instead:

  • Run a single relay worker for the events that need strict order, a separate scaled-out pool for the rest.
  • Or carry a per-aggregate sequence number on the event and let the consumer reject anything older than the last one it applied.

Global ordering across an outbox is a property you pay for with throughput. Ask for it only where the domain actually needs it.

At-least-once means idempotent handlers

The outbox guarantees at-least-once delivery. A worker can crash after doing the work but before deleting the row, and the next worker will replay that message. Your handlers have to tolerate seeing the same event twice.

The cheap, reliable guard is a processed-messages table with the event's own id as the primary key. Put a stable id on the event when you create it, not Messenger's internal envelope id.

use Symfony\Component\Uid\Uuid;

final class OrderPlaced
{
    public readonly string $eventId;

    public function __construct(
        public readonly string $orderId,
    ) {
        // stable id, generated once at creation
        $this->eventId = Uuid::v7()->toRfc4122();
    }
}
Enter fullscreen mode Exit fullscreen mode
final class ProcessedEvents
{
    public function __construct(private Connection $conn) {}

    public function firstTime(string $eventId): bool
    {
        // Postgres. On MySQL use INSERT IGNORE.
        $rows = $this->conn->executeStatement(
            'INSERT INTO processed_events (id, seen_at)
             VALUES (?, NOW())
             ON CONFLICT (id) DO NOTHING',
            [$eventId],
        );

        return $rows === 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler checks it before doing anything with side effects:

public function __invoke(OrderPlaced $event): void
{
    if (!$this->processed->firstTime($event->eventId)) {
        return; // already handled, replay is a no-op
    }

    $this->billing->chargeFor($event->orderId);
}
Enter fullscreen mode Exit fullscreen mode

A replay inserts nothing, firstTime() returns false, the handler exits. The ON CONFLICT insert is atomic, so even two workers racing the same replay only let one through.

Where this leaves you

Three settings, no custom infrastructure. Route the event to a doctrine:// transport so the record lands in the business transaction. Run messenger:consume as the relay so delivery survives a broker outage. Guard the handler with a processed-events table so replays are safe. DispatchAfterCurrentBus still has a place for fire-and-forget work you do not want to persist, but for anything a downstream system must not miss, the outbox table is the honest answer.

The pieces are all in the framework. The pattern is the part you own.

What tips your events into needing an outbox: the broker flaking, the ghost-record chargebacks, or an audit that asked where an event went?


If this was useful

The outbox is a small example of a bigger habit: keeping delivery and infrastructure concerns at the edge, so your domain emits a plain OrderPlaced and never learns whether a table, RabbitMQ, or a relay carried it. That boundary is what lets you swap the transport later without touching a line of business logic. Decoupled PHP is about drawing exactly those lines: what the framework owns, what your domain owns, and how to keep an event honest as the plumbing under it changes.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)