Europe/Paris
Posts

Symfony 8.2 Messenger outbox and inbox: saving and publishing in one transaction, handling each message once

November 9, 2026 · 6 min de lecture
You save an order to the database, then send an OrderPlaced event to RabbitMQ: two writes to two systems, and nothing makes them atomic. Symfony 8.2 adds two Messenger options, outbox and inbox, that close both ends of that gap. Here is what they guarantee, how to wire them, and where they stop. The classic code looks like this:
Php
$this->entityManager->persist($order);
$this->entityManager->flush();

$this->bus->dispatch(new OrderPlaced($order->id(), $order->customerId(), $order->totalInCents()));
If the PHP process dies between flush() and dispatch(), or the broker is unreachable at that moment, the order exists and nobody is told. Swap the two lines and you get the reverse: an event for an order that was never committed. This is the dual write problem (writing to two systems that share no transaction). On the consumer side, a broker such as RabbitMQ or SQS delivers at least once: a message can arrive twice. The worker commits the handler's changes, crashes before acknowledging the message, and the broker delivers it again. As Oskar Dudycz summarizes on event-driven.io about at-least-once delivery: "The disadvantage is that the message can be handled several times." The result: loyalty points credited twice, two confirmation emails. The known answers are two patterns. The transactional outbox writes the message to the database in the same transaction as the business data, and a separate process publishes it later. The inbox records the ID of every processed message in the same transaction as the handler's changes, so a redelivery can be detected and skipped. Dudycz explains both, along with their cost, in an article from 2020 that remains a solid reference on the subject. What changes now is that Symfony ships both as plain transport options. As Javier Eguiluz explains on the Symfony blog, the outbox option (contributed by Nicolas Grekas) stores messages bound for a transport in a Doctrine transport first. A relay worker then forwards them to the real broker. Consumers keep reading from the broker as before. The outbox table is a short stopover for messages, not the queue that consumers poll. The usual workaround until now was to use the Doctrine transport as the queue itself. It works, but the database then carries the polling of every worker. The inbox option, presented five weeks later on the same blog and also by Nicolas Grekas, records the transport name and message ID in a messenger_inbox table, inside the handler's transaction. A message that is already in the table is acknowledged without running its handlers. If the handler fails, the rollback removes the inbox entry as well, so the retry works normally. If two workers receive the same message, the second one waits for the first one's transaction to finish. The announcements show each option on its own and do not document their combination. Nothing in them forbids setting both on the transport that carries the events, since the outbox acts when sending and the inbox when receiving, but test that setup before relying on it:
Yaml
# config/packages/messenger.yaml
framework:
    messenger:
        # adds the message ID the inbox needs
        identity_stamps: true

        transports:
            orders:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                outbox: db_outbox
                inbox: true
            db_outbox: 'doctrine://default?queue_name=outbox'

        routing:
            App\Order\Domain\OrderPlaced: orders

        buses:
            messenger.bus.default:
                # the inbox requires handlers to run inside a transaction
                middleware: [doctrine_transaction]
The outbox must be a Doctrine transport on the same connection as the business data, here default. That is the condition for the message row and the order row to share a transaction. The inbox has the same constraint: it needs symfony/doctrine-messenger and the handlers' connection (inbox: { connection: billing } if it is not the default one). On the producer side, the dispatch has to happen inside the transaction. wrapInTransaction() opens it, runs the closure, flushes and commits. If the closure throws, it rolls back and closes the EntityManager, which matters in long-running workers:
Php
<?php

declare(strict_types=1);

namespace App\Order\Application;

use App\Order\Domain\Order;
use App\Order\Domain\OrderPlaced;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class PlaceOrder
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private MessageBusInterface $bus,
    ) {
    }

    public function __invoke(string $customerId, int $totalInCents): Order
    {
        return $this->entityManager->wrapInTransaction(
            function () use ($customerId, $totalInCents): Order {
                // the ID is a UUID generated by the domain, no flush needed to know it
                $order = Order::place($customerId, $totalInCents);
                $this->entityManager->persist($order);

                // INSERT into the outbox table, same connection, same transaction
                $this->bus->dispatch(new OrderPlaced($order->id(), $customerId, $totalInCents));

                return $order;
            },
        );
    }
}
Either the order and the message are both committed, or neither is. On the consumer side, the handler does not need to change:
Php
<?php

declare(strict_types=1);

namespace App\Loyalty\Application;

use App\Loyalty\Domain\LoyaltyAccountRepository;
use App\Order\Domain\OrderPlaced;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class GrantLoyaltyPoints
{
    public function __construct(private LoyaltyAccountRepository $accounts)
    {
    }

    public function __invoke(OrderPlaced $event): void
    {
        $account = $this->accounts->forCustomer($event->customerId);
        $account->credit(intdiv($event->totalInCents, 100));
        // the doctrine_transaction middleware flushes and commits,
        // inbox entry included
    }
}
What remains is creating the tables and running two kinds of workers:
Bash
# creates the outbox and messenger_inbox tables (or generate a Doctrine migration)
php bin/console messenger:setup-transports

# the relay: forwards stored messages to "orders", never runs handlers
php bin/console messenger:consume db_outbox

# the usual consumers
php bin/console messenger:consume orders
The relay is one more process to supervise. If it stops, nothing is lost, but nothing goes out either: monitor the size of the outbox queue (messenger:stats shows it) the same way you monitor the broker. A few rules from the announcements that change how you operate it: a delay set on a message is served while it waits in the outbox, and it is forwarded without delay afterwards. A forwarding failure uses the retry strategy and failure transport of the outbox, while a handling failure retries on the target transport. If you sign the orders transport (another 8.2 option), its outbox must sign too, or the container will not compile. All of this is in Symfony 8.2, which is not stable yet: 8.2.0-BETA1 was published on November 4, 2026. It is the right time to test it on a branch, not to deploy it. On an earlier version, the Doctrine transport used as the queue covers the producer side, and a hand-made idempotency key (a unique ID stored with the result, so a replay is detected and ignored) covers the consumer side, the approach I describe in Chronos Billing Engine.
  • Dispatching outside the transaction. A dispatch() after a flush() in a controller is written to the outbox in a second transaction, after the order's commit: a crash between the two commits loses the event. That is the dual write problem again, only the broker-down case goes away. Same issue with an outbox on a different connection than the business data.
  • Believing the inbox makes everything idempotent. It protects writes made in the handler's transaction. An email sent or a payment API called before a crash will be repeated on redelivery. Use an idempotency key on the external API, or move that call into a separate message. Batch handlers are not recorded in the inbox at all.
  • Getting timings and order wrong. The inbox's retention (7 days by default) must be longer than the broker's longest redelivery window. The outbox does not guarantee forwarding order: a consumer that depends on order needs to check versions itself.
Last detail: messages dispatched with DispatchAfterCurrentBusStamp from a handler covered by the inbox must go to a transport with an outbox. The inbox entry is committed before they are sent, and Messenger refuses to send them otherwise.
On this page
Book a call