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.

PHP src/Message/ChargeCustomer.php
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.

PHP src/MessageHandler/ChargeCustomerHandler.php
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.

PHP src/Message/OrderPlaced.php
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.

YAML config/packages/messenger.yaml
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.

PHP src/Application/Order/PlaceOrderService.php
$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.

YAML config/packages/messenger.yaml
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.

Bash
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.

A horizontal engineering diagram showing a Symfony Messenger message moving from a controller or application service through the message bus, routing, transport queue, worker, and handler. Branches show the success path and a failure transport for messages that exhaust retries.
Symfony Messenger flow: dispatch → bus → routing → transport → worker → handler → success or failure transport.

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:

YAML config/packages/messenger.yaml
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):

Bash
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.

YAML config/packages/messenger.yaml
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.

PHP src/MessageHandler/SendReceiptHandler.php
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.

YAML config/packages/messenger.yaml
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.

Bash
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:

Bash
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:

INI /etc/supervisor/conf.d/messenger-async.conf
[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:

  1. messenger:stop-workers after 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.
  2. 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

  1. Putting too much logic in the message — A message should describe work, not become a mini service object.
  2. Dispatching before the database transaction commits — A worker can pick up a message before related data is visible if you are careless around transactions.
  3. Making every message async by default — Async is powerful, but not every action deserves eventual consistency.
  4. Ignoring worker operations — Workers need restarts, memory limits, monitoring, and deployment discipline.
  5. 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

  1. Name messages after business intentApproveRefund is better than RefundJob because it tells you why the work exists.
  2. Keep handlers small — A handler should orchestrate one use case, not rebuild your service layer in disguise.
  3. Track message IDs — Logging message class and business IDs makes debugging async flows much easier.
  4. Separate slow work from critical work — Email and analytics should not block payment confirmation.
  5. 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 👊