Using Symfony Messenger with Doctrine Transport for Reliable Async Processing
Learn how to set up Symfony Messenger with the Doctrine transport for reliable async work, complete with a message/handler example, consumption command, and operational trade‑offs.
02 Mar 2026, 11:54 UTC

Problem: slowing down the request with synchronous work
When a user registers, you often need to send a welcome email, update external services, or log analytics. Doing that work directly in the controller makes the HTTP response wait for I/O, which hurts user experience and can cause timeouts if the external service is slow.
Thesis: Doctrine transport gives you zero‑ops, transactionally safe async without adding a separate broker
Symfony Messenger’s Doctrine transport stores messages in a regular database table and relies on the DB’s transactional guarantees for durability. You get exactly‑once delivery within a transaction, no extra infrastructure, and the same familiar development workflow.
1. Configuring the Doctrine transport
First, ensure you have the Messenger and Doctrine ORM packages installed (Symfony 6.4 LTS used here).
# Run in the project root; you need read/write access to the filesystem and permission to execute php bin/console
composer require symfony/messenger symfony/doctrine-orm
Then configure the transport in config/packages/messenger.yaml:
framework:
messenger:
transports:
# The Doctrine transport uses the default messenger_messages table
doctrine: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
# Route your message classes to the Doctrine transport
'App\\Message\\SendWelcomeEmail': doctrine
The DSN can be left as the default doctrine://default or set via an environment variable if you use a different connection.
2. Defining a message and its handler
Create a simple message class that carries the data needed for the welcome email.
// src/Message/SendWelcomeEmail.php
namespace App\Message;
class SendWelcomeEmail
{
public function __construct(
public int $userId,
public string $userEmail,
) {}
}
Next, implement the handler that actually sends the email using Symfony’s Mailer.
// src/MessageHandler/SendWelcomeEmailHandler.php
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
class SendWelcomeEmailHandler implements MessageHandlerInterface
{
public function __construct(private MailerInterface $mailer) {}
public function __invoke(SendWelcomeEmail $message): void
{
$email = (new Email())
->from('hello@example.com')
->to($message->userEmail)
->subject('Welcome to our service')
->text('Hi there, thanks for signing up!');
$this->mailer->send($email);
// In a real app you might also log or update a user flag here
}
}
Because the handler implements MessageHandlerInterface, Symfony automatically tags it; no extra service configuration is needed.
3. Dispatching and consuming the message
Dispatch the message from anywhere—here’s an example in a controller after persisting a new user.
// src/Controller/RegistrationController.php
namespace App\Controller;
use App\Message\SendWelcomeEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
public function __construct(private MessageBusInterface $bus) {}
public function register(): Response
{
// ... persist $user entity ...
$this->bus->dispatch(new SendWelcomeEmail($user->getId(), $user->getEmail()));
return $this->redirectToRoute('login');
}
}
To process the message, run the consumer command. It will poll the messenger_messages table, execute the handler, and delete the row on success.
# Run in the project root; requires permission to execute php and access the database
php bin/console messenger:consume doctrine -vv
Expected checks:
- Watch the output: you should see a line like
Received SendWelcomeEmail messagefollowed by the Mailer log and thenMessage handled successfully. - Inspect the
messenger_messagestable (e.g.,SELECT * FROM messenger_messages;) before and after running the consumer; the row for the dispatched message should disappear after successful handling. - If the handler throws an exception, the message will be retried according to the redelivery limit (default 3). You can simulate this by throwing
\Throwablein the handler and observing the retry count in the table’sredeliveredcolumn.
4. Trade‑offs and limitations
While the Doctrine transport is easy to start with, it has characteristics you should monitor:
- Polling overhead: The consumer uses long‑polling; each idle loop issues a cheap SELECT, but at high volume this can add measurable DB load. Keep an eye on database CPU and consider increasing the
--limitor using a dedicated broker if you consistently process thousands of messages per minute. - Table growth: Failed or delayed messages accumulate. Implement a periodic cleanup (e.g., a cron that runs
php bin/console messenger:failed:removeor a custom DELETE for messages older than a safe window). - Throughput: Compared to RabbitMQ or Redis, the Doctrine transport typically handles lower messages‑per‑second rates because each message incurs a full DB transaction. For most line‑of‑business apps with modest async needs, this is acceptable.
Practical way to verify you’re within safe limits: after a peak period, run SHOW TABLE STATUS LIKE 'messenger_messages'; and check the Data_length and row count. If the table is growing faster than you can consume, consider scaling out consumers or switching transport.
Closing: when to choose the Doctrine transport
If you need reliable async processing, want zero‑ops setup, and your message volume is modest (under a few hundred per minute), the Doctrine transport gives you exactly‑once delivery with minimal friction. Use the worked example above as a template, monitor the messenger_messages table, and plan a cleanup strategy for failed messages. For higher throughput or more advanced routing, evaluate a dedicated broker later—your code and handler stay the same, only the transport changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.