You know that moment when a controller starts doing a little too much?
At first, it creates an order. Then it sends an email. Then it calls a billing API. Then it updates analytics. Then it syncs CRM data. Then someone adds a Slack notification because "it's only one more line."
That's how a simple HTTP request becomes a tiny production hostage situation.
Symfony Messenger helps you stop treating every business action as something that must happen immediately, inside the same request, with the same failure mode. It gives you message buses, handlers, transports, retries, and failure handling so your application can breathe.
The important part is this: Messenger is not only about "put this job on a queue." That's the beginner version. The real power is using messages to model intent.
Start With Intent, Not Infrastructure
A good async workflow starts with a boring question: what actually happened, and what do you want the system to do next?
That sounds simple, but it changes the design. A message is like a labeled package in a warehouse. If the label says "StuffToDoLater," nobody knows how fragile it is, where it should go, or what happens if delivery fails. If the label says SendWelcomeEmail, the route becomes obvious.
Commands Are Requests To Do Something
A command message represents an action you want the system to perform. It is usually imperative: create this invoice, charge this payment, sync this customer.
Here is a small command object.
namespace App\Message;
final readonly class ChargeCustomer
{
public function __construct(
public string $orderId,
public int $amountInCents,
) {}
}
This message does not charge anything by itself. It only describes the work clearly enough that a handler can perform it.
namespace App\MessageHandler;
use App\Message\ChargeCustomer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class ChargeCustomerHandler
{
public function __invoke(ChargeCustomer $message): void
{
// Load order, call payment gateway, record result.
}
}
The takeaway is simple: the message is the contract, and the handler is the worker that fulfills it.
Events Are Facts That Already Happened
An event message describes something that already happened. It is usually past tense: order placed, payment captured, user registered.
That difference matters. A command expects one clear owner. An event can have many listeners.
namespace App\Message;
final readonly class OrderPlaced
{
public function __construct(public string $orderId) {}
}
One handler can send the receipt. Another can update reporting. Another can schedule fulfillment.
That's cleaner than making OrderService know every downstream side effect like it's the mayor of your entire application.
Message Buses Give You Boundaries
Symfony can work with multiple message buses. You might keep one bus for commands, another for events, and maybe another for queries if your architecture needs it.
Think of buses like lanes on a highway. Trucks, ambulances, and bicycles technically all move things around, but you probably don't want them fighting for the same lane.
framework:
messenger:
default_bus: command.bus
buses:
command.bus: ~
event.bus:
default_middleware: allow_no_handlers
This is not mandatory for every app. A small product can live happily with one bus. But once business workflows grow, separate buses make intent easier to see.
Keep Dispatching Boring
Dispatch messages from application services, controllers, console commands, or event subscribers when it makes sense.
$this->commandBus->dispatch(new ChargeCustomer(
orderId: $order->getId(),
amountInCents: $order->totalInCents(),
));
$this->eventBus->dispatch(new OrderPlaced($order->getId()));
This keeps your core flow readable. Place order. Charge customer. Announce order placed. No hidden circus.
Transports Decide Where Work Runs
A transport is where Messenger sends messages when you do not want them handled immediately. Symfony supports transports like Doctrine, AMQP, Redis, and others depending on installed packages and configuration.
The key idea is that transport is infrastructure. Your message class should not care whether it goes through PostgreSQL, RabbitMQ, Redis, or a sync handler in tests.
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Message\ChargeCustomer: async
App\Message\OrderPlaced: async
This configuration says those messages should be handled by workers instead of inside the current request.
php bin/console messenger:consume async --time-limit=3600
The worker becomes the kitchen behind the restaurant. The HTTP request takes the order; the worker cooks the meal.
Routing And Multiple Transports
Real systems usually need more than one transport. Slow, low-priority work should not sit behind fast, critical work.
The simplest practical setup is to split by priority, not by technology:
framework:
messenger:
transports:
high: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=high'
default: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=default'
low: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=low'
routing:
App\Message\ChargeCustomer: high
App\Message\OrderPlaced: default
App\Message\RebuildSearchIndex: low
Then each worker consumes a specific queue (or several, in priority order):
php bin/console messenger:consume high default --time-limit=3600
php bin/console messenger:consume low --time-limit=3600
Now an emergency ChargeCustomer doesn't queue behind a four-minute search reindex. The customer-facing payment lives on the high queue and gets a dedicated worker pool.
A useful pattern: at least one queue per response-time SLA. If "send receipt within 1 minute" and "rebuild analytics within 1 hour" share a queue, the slower SLA always wins when traffic spikes.
Retries Are A Product Decision, Not A Checkbox
Retries look technical, but they are really business behavior.
If Stripe is temporarily unavailable, retrying makes sense. If a user ID does not exist, retrying five times only makes the logs louder. That's like repeatedly knocking on a door after you've confirmed the building was demolished.
Symfony lets you configure retry behavior per transport.
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 10000
This gives you exponential-ish backoff: wait, try again, wait longer, try again.
Design Handlers To Be Retry-Safe
A handler should be idempotent whenever possible. That means running it twice should not create two payments, two shipments, or two refunds.
public function __invoke(SendReceipt $message): void
{
$order = $this->orders->get($message->orderId);
if ($order->receiptWasSent()) {
return;
}
$this->mailer->sendReceipt($order);
$order->markReceiptSent();
$this->orders->save($order);
}
The guard looks boring, but boring guards save production systems.
You can also opt specific exceptions out of retry. Symfony provides UnrecoverableMessageHandlingException — throw it from a handler to tell Messenger "do not retry this; send it straight to the failure transport." Use it for validation failures, missing records, and any error a retry can't fix.
Failure Handling Is Part Of The Workflow
A failed message should not disappear into the void. Symfony supports a failure transport, where messages can land after retries are exhausted.
framework:
messenger:
failure_transport: failed
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
failed: 'doctrine://default?queue_name=failed'
Now your team has a place to inspect failures.
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
php bin/console messenger:failed:remove
That is the difference between "the queue is broken" and "three payment-capture messages failed because the gateway returned a timeout."
Worker Operations And Memory Limits
Workers are long-running PHP processes. That sentence alone should make you treat them differently from request-response code.
A worker that runs forever will eventually leak memory, hold a stale connection, or pin an out-of-date class definition. The fix isn't "write perfect handlers" — it's running workers under supervision with sensible recycling.
The Messenger consume command already supports this:
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=256M \
--limit=1000
That worker will exit cleanly after one hour of runtime, after 256MB of memory use, or after handling 1000 messages — whichever comes first. A process supervisor like systemd or Supervisor restarts it immediately, which gives you a fresh container, fresh database connections, and the latest deployed code.
A minimal supervisord block looks like this:
[program:messenger-async]
command=php /srv/app/bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
user=www-data
numprocs=4
autostart=true
autorestart=true
process_name=%(program_name)s_%(process_num)02d
Two more habits that pay back many times over:
messenger:stop-workersafter deploys. This sends a stop signal to all running workers; the supervisor restarts them with the new code. Without it, workers keep running yesterday's classes until they hit their time limit.- Monitor queue depth and oldest-message age. A queue that's growing faster than it drains is the early signal of an outage. Pulse, Datadog, or a tiny Prometheus exporter is enough to catch it.
A worker is part of your production surface. Treat it like one.
Common Messenger Problems
- Putting too much logic in the message — A message should describe work, not become a mini service object.
- Dispatching before the database transaction commits — A worker can pick up a message before related data is visible if you are careless around transactions.
- Making every message async by default — Async is powerful, but not every action deserves eventual consistency.
- Ignoring worker operations — Workers need restarts, memory limits, monitoring, and deployment discipline.
- Retrying non-retryable failures — Validation errors and missing records usually need rejection, not repeated attempts.
Messenger is a workflow tool, not magic tape for messy code.
Pro Tips
- Name messages after business intent —
ApproveRefundis better thanRefundJobbecause it tells you why the work exists. - Keep handlers small — A handler should orchestrate one use case, not rebuild your service layer in disguise.
- Track message IDs — Logging message class and business IDs makes debugging async flows much easier.
- Separate slow work from critical work — Email and analytics should not block payment confirmation.
- Document failure recovery — If a failed message appears, the team should know whether to retry, remove, or fix data first.
Final Tips
I've seen queue systems treated like a junk drawer: if code is slow, throw it into async and hope production forgives you. It never really works that way. The mess just moves from HTTP latency into invisible operational debt.
The better approach is to design messages like contracts. Give each one a clear purpose, make handlers retry-safe, and treat failure handling as part of the feature.
Symfony Messenger is excellent when you use it deliberately. Go build workflows that survive real traffic 👊




