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.
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:
- Symfony Messenger Documentation — Symfony
- The Symfony Messenger Component — SymfonyCasts
- Messenger: Queue and Handle Messages — Symfony Components