Two failures nobody sees in development
Php
$this->entityManager->persist($order);
$this->entityManager->flush();
$this->bus->dispatch(new OrderPlaced($order->id(), $order->customerId(), $order->totalInCents()));
The outbox: the database becomes a waiting room
The inbox: the other half
A complete config
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]
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;
},
);
}
}
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
}
}
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
Where it stands
The traps
- 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.
Sources
- New in Symfony 8.2: Messenger Outbox and Claim Check — Symfony Blog, Javier Eguiluz, September 21, 2026
- New in Symfony 8.2: Messenger Inbox — Symfony Blog, Javier Eguiluz, October 26, 2026
- Outbox, Inbox patterns and delivery guarantees explained — event-driven.io, Oskar Dudycz, December 30, 2020
- New in Symfony 8.2: Signed Transports and Secret Rotation — Symfony Blog, Javier Eguiluz, October 21, 2026
- Symfony 8.2.0-BETA1 released — Symfony Blog, Fabien Potencier, November 4, 2026