6 min read

Symfony Messenger: The Component PHP Developers Keep Underusing

Symfony Messenger handles async queues, retries, and message buses elegantly. Here's a practical guide to using it properly in your PHP apps.

Featured image for "Symfony Messenger: The Component PHP Developers Keep Underusing"

If you’ve been using Symfony for more than a few months, you’ve probably heard of the Messenger component. You may even have it installed. But a surprising number of PHP developers use it only as a basic job queue — fire a message, handle it, done — without tapping into the features that make it genuinely powerful.

Messenger is Symfony’s answer to message-driven architecture: it handles dispatching, routing, transports, retries, failure queues, and middleware in a unified, composable way. Used well, it can replace dedicated queue libraries and background job runners while staying fully integrated with your Symfony application. Here’s how to use it properly.

Installing and Wiring It Up

If you’re on Symfony 6.4 or 7.x and haven’t added Messenger yet:

composer require symfony/messenger

The Flex recipe will scaffold a basic configuration in config/packages/messenger.yaml. Out of the box you get a synchronous transport, which is fine for development and testing — messages are handled immediately in the same process.

For production you’ll want an async transport. Doctrine is the easiest if you’re already using the ORM:

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    use_notify: true
                    check_delayed_interval: 1000
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
                    max_delay: 0

        routing:
            App\Message\SendWelcomeEmail: async
            App\Message\ProcessPayment: async

Set your .env:

MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0

Run the migration to create the messenger queue table:

php bin/console messenger:setup-transports

Messages Are Just Plain PHP Objects

This is the right mindset shift. A Messenger message isn’t a special framework class — it’s a plain PHP object (a DTO) that carries the data needed to complete a task:

// src/Message/SendWelcomeEmail.php
namespace App\Message;

final class SendWelcomeEmail
{
    public function __construct(
        public readonly int $userId,
        public readonly string $email,
    ) {}
}

That’s it. No interface, no base class. The message carries data; the handler does the work.

Handlers: Single Responsibility Done Right

A handler is a class that processes one message type. Messenger discovers it automatically if you tag it with #[AsMessageHandler]:

// src/MessageHandler/SendWelcomeEmailHandler.php
namespace App\MessageHandler;

use App\Message\SendWelcomeEmail;
use App\Repository\UserRepository;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;

#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
    public function __construct(
        private UserRepository $users,
        private MailerInterface $mailer,
    ) {}

    public function __invoke(SendWelcomeEmail $message): void
    {
        $user = $this->users->find($message->userId);

        $email = (new Email())
            ->to($message->email)
            ->subject('Welcome!')
            ->html('<p>Thanks for joining!</p>');

        $this->mailer->send($email);
    }
}

The type hint on __invoke is what Messenger uses to route messages to the right handler. No configuration needed.

Dispatching is equally simple:

// In a controller or service
$this->bus->dispatch(new SendWelcomeEmail($user->getId(), $user->getEmail()));

Multiple Buses for Different Purposes

Messenger ships with three pre-configured bus types that you should use for their intended purposes:

  • command.bus — one-way instructions, no return value expected. Use for “do this thing.”
  • query.bus — asks for data and expects a return value. Use for “give me this data.”
  • event.bus — broadcasts that something happened, zero or many handlers. Use for “this thing happened.”

Enable them in your configuration:

framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus:
                middleware:
                    - validation
            query.bus:
                default_middleware:
                    enabled: true
                    allow_no_handlers: false
            event.bus:
                default_middleware:
                    enabled: true
                    allow_no_handlers: true

The event bus is particularly useful for decoupling: when a user registers, dispatch a UserRegistered event and let multiple handlers respond independently — one sends the welcome email, another creates a trial subscription, another notifies your analytics service. None of those handlers know about each other.

// Multiple handlers, all triggered by one dispatch
#[AsMessageHandler(bus: 'event.bus')]
final class SendWelcomeEmailOnRegistration
{
    public function __invoke(UserRegistered $event): void { /* ... */ }
}

#[AsMessageHandler(bus: 'event.bus')]
final class StartTrialSubscription
{
    public function __invoke(UserRegistered $event): void { /* ... */ }
}

Retry Logic and Failure Queues

Production queue workers fail. Network hiccups, third-party API timeouts, database deadlocks — handlers throw exceptions. Messenger’s retry strategy handles this automatically:

transports:
    async:
        retry_strategy:
            max_retries: 3
            delay: 1000      # 1 second initial delay
            multiplier: 2    # exponential: 1s, 2s, 4s
            max_delay: 0     # no cap

After max_retries attempts, failed messages go to the failure transport rather than disappearing:

framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
            failed:
                dsn: 'doctrine://default?queue_name=failed'

You can inspect and retry failed messages from the command line:

# List all failed messages
php bin/console messenger:failed:show

# Retry a specific message by ID
php bin/console messenger:failed:retry 42

# Retry all failed messages
php bin/console messenger:failed:retry

This failure queue pattern is essential for production — it’s how you avoid silently losing work when things go wrong.

Stamps for Metadata and Scheduling

Stamps let you attach metadata to a message envelope without modifying the message itself. The most useful one for everyday work is DelayStamp:

use Symfony\Component\Messenger\Stamp\DelayStamp;

// Dispatch with a 5-minute delay
$this->bus->dispatch(
    new SendAbandonedCartEmail($cart->getId()),
    [new DelayStamp(5 * 60 * 1000)] // milliseconds
);

The ValidationStamp, TransportNamesStamp, and ReceivedStamp each serve different coordination purposes. And if you need custom metadata — say, tracking which tenant dispatched a message in a multi-tenant app — you can create your own stamps:

use Symfony\Component\Messenger\Stamp\StampInterface;

final class TenantStamp implements StampInterface
{
    public function __construct(public readonly string $tenantId) {}
}

Then read it in your handler:

use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Envelope;

public function __invoke(ProcessImport $message, Envelope $envelope): void
{
    $stamp = $envelope->last(TenantStamp::class);
    $tenantId = $stamp?->tenantId;
    // Route database connections, logging, etc.
}

Running Workers in Production

The messenger:consume command runs your queue workers:

php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

The --time-limit and --memory-limit flags are important for long-running workers — they prevent memory leaks from accumulating over time by gracefully restarting after limits are hit. In production, use Supervisor or systemd to keep workers running and restart them when they exit.

A minimal Supervisor config:

[program:messenger-worker]
command=php /var/www/html/bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
user=www-data
numprocs=2
autostart=true
autorestart=true
startsecs=0
process_name=%(program_name)s_%(process_num)02d

Two processes gives you parallel handling with no shared state issues, since each worker instance is fully independent.

Messenger vs. Laravel Queues

If you’re coming from Laravel, the comparison is instructive. Both systems accomplish the same goal — async message processing — but with different philosophies. Laravel’s queue system is more configuration-driven and tightly integrated with the framework’s ecosystem (Horizon, Telescope). Symfony Messenger is more explicit: you define buses, transports, and routing in YAML, and the code structure (plain objects, typed handlers) is more rigid by design.

Neither is universally better. For complex message routing, multi-bus architectures, or CQRS patterns, Messenger’s structure pays off. For straightforward background job processing in a Laravel app, the native queue system is simpler to reason about.

The key insight is that Messenger was built to model domain workflows, not just offload slow operations. When you need to represent commands, queries, and domain events as first-class objects with proper routing, middleware, and failure handling, Messenger is one of the best-designed systems in the PHP ecosystem.

Start with the async transport, add a failure queue on day one, and grow from there.

Sources: