Symfony Messenger as the Smallest Async Design: Boundaries, Checks, and Failure Modes
When to move work out of the HTTP request with Symfony Messenger, how to keep the message payload untrusted, and which failure modes at-least-once delivery makes normal.
27 Jul 2026, 12:41 UTC

The requirement that justifies a queue at all
Move work out of the HTTP request only when the response must return before the side effect finishes: sending a receipt email, calling a third-party API, rendering a PDF, posting a webhook. If the side effect must be atomic with the request — a payment capture that has to succeed or roll back with the order row — keep it synchronous. A queue converts a database transaction into a retry problem, and that trade is only worth making when the caller genuinely does not need the result.
Everything below assumes that condition holds. If it does not, the correct smallest design is no queue.
The smallest suitable design
One bus, one async transport, one handler per message class, and no custom transport code. Symfony's Messenger component supplies all four. Anything beyond that — multiple buses, per-message routing rules, a hand-written transport — should be a decision you can state in one sentence.
A message is a plain object carrying identifiers, not entities. The handler is an ordinary autowired service.
namespace App\Message;
final class SendReceipt
{
public function __construct(public readonly int $orderId) {}
}
Dispatch it from the controller through the bus interface:
$bus->dispatch(new SendReceipt($order->getId()));
Routing lives in configuration, not in the message class. The transport DSN is the only piece that changes when you later move to a broker.
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\SendReceipt: async
The database-backed Doctrine transport stores rows in a messenger_messages table. Create it with php bin/console messenger:setup-transports from the project root, using an account that can alter the schema. This avoids introducing new infrastructure at low to moderate volume, which is exactly why it is the right default to start from.
Handler registration has changed across major versions. Recent releases support the #[AsMessageHandler] attribute; older ones rely on the MessageHandlerInterface marker or on the messenger.message_handler tag. Confirm which applies to the version you have installed rather than copying a snippet from a different release line.
Trust boundary: the payload is data, not authority
A message is a claim about the past, not a permission slip. Anything derived from a request — identifiers, amounts, URLs, tenant — must be re-read and re-validated inside the handler against current persisted state. The message may be replayed, delayed by hours, or produced by a different writer on the same transport.
#[AsMessageHandler]
final class SendReceiptHandler
{
public function __construct(
private OrderRepository $orders,
private MailerInterface $mailer,
) {}
public function __invoke(SendReceipt $message): void
{
$order = $this->orders->find($message->orderId);
if ($order === null || !$order->isPaid()) {
return; // terminal state; retrying will not change it
}
// build and send using $order, never using values from the payload
}
}
Never place credentials, session tokens, or raw user input in the payload. The worker process runs with the same application privileges as the web process, so a handler that trusts message contents effectively trusts anyone who can write to the transport — the database table, the broker, or another service sharing the queue. Restrict write access to that table accordingly.
The failure transport is the second edge of the same boundary. It stores full payloads plus exception traces, so treat it as sensitive stored data with a retention policy and a cleanup command rather than an append-only log nobody reads.
Operational checks for long-running workers
A worker is a long-lived PHP process, and PHP was not designed for that. Run it under a supervisor that restarts it on exit, and give the consume command explicit recycling limits so memory leaks and stale connections do not accumulate:
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=128M \
--limit=500
Option names and availability differ across versions; check the command help on the installed release with php bin/console messenger:consume --help. The values above are starting points, not recommendations — set them from observed resident memory over several cycles.
Monitor four signals, not one:
- Queue depth, and the age of the oldest unprocessed message. Depth alone hides a stuck consumer.
- Retry counts per message class, which reveal a handler failing on a subset of inputs.
- Failure-transport size. Alert on growth; a worker that exits cleanly after moving every message to the failure transport looks healthy to a process monitor.
- Worker restart frequency, which distinguishes a crash loop from normal recycling.
Failure modes under at-least-once delivery
Delivery is at-least-once. Treat duplicate, delayed, and out-of-order processing as normal operating conditions rather than incidents.
| Failure mode | What it looks like | Practical guard |
|---|---|---|
| Duplicate delivery | Two receipt emails for one order | Idempotency key or unique constraint on the side effect |
| Out-of-order | Cancel processed before create | Re-read state in the handler; do not assume sequence |
| Poison message | One payload retried forever | Bounded retry strategy plus a failure transport |
| Silent loss | Message consumed but side effect skipped | Log terminal no-op branches and alert on their rate |
A throwing handler is retried according to the transport's retry strategy and then moved to the failure transport. Retry counts, delays, and multipliers are version- and transport-specific; read the installed configuration reference instead of assuming a default. An unbounded or very large retry budget lets a single poison message occupy a queue slot indefinitely.
Conditions that would change the design
Keep the database transport until one of these is true, then move deliberately:
- Sustained throughput exceeds what a polling, row-locking table can serve without contention.
- You need strict ordering, delayed or scheduled delivery, or fan-out to multiple independent consumers.
- Other services must publish to or consume from the same stream.
A dedicated broker addresses all three. It does not address exactly-once side effects. If the requirement is that a side effect happens once and only once, the design change is a transactional outbox — write the message row in the same transaction as the state change, then relay it — combined with idempotency keys on the consumer side. That is a different problem from transport choice, and swapping brokers will not solve it.
Verifying the design in your environment
These checks are a plan, not a report of results; run them against your own installation.
- Confirm the installed Symfony version and read the Messenger configuration reference that ships with it before trusting any key name above.
- Dispatch one message and consume with a short time limit:
php bin/console messenger:consume async --time-limit=10. Confirm the handler ran. - Throw inside the handler, consume again, and observe the retry attempts and where the message finally lands. Inspect it with
php bin/console messenger:failed:show. - Query the
messenger_messagestable after a failed run to see what metadata is stored and how failed messages are represented. - Dispatch the same message twice and check whether the side effect is duplicated. If it is, the handler is not yet idempotent.
- Run a worker with memory and time limits set and watch resident memory across several recycling cycles.
Limitations of this note
No throughput or latency figures appear here because they depend on your database, payload size, and handler cost; measure queue depth and processing time in the actual environment. The polling and locking behaviour of a database-backed transport under high concurrency is not equivalent to a dedicated broker, and the point at which it becomes inadequate cannot be predicted from configuration alone. Configuration keys, transport names, and console options vary across Symfony major versions, so verify syntax against the documentation for the release you have installed before relying on any snippet.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.