Offload Email Sending in Symfony with Messenger and RabbitMQ
Learn how to offload heavy email sending in Symfony using Messenger and RabbitMQ. A concise guide covers the problem, architecture, step‑by‑step example, trade‑offs, and practical next steps.
07 Jun 2026, 22:28 UTC

Problem: Blocking HTTP Requests with Synchronous Email
When a user submits a form that triggers an email, the controller usually calls MailerInterface::send() directly. The HTTP request waits for the SMTP handshake, TLS negotiation, and delivery confirmation. Under load, this latency can lead to timeouts, a poor user experience, and higher server resource consumption.
Solution Overview: Decouple with Symfony Messenger
The Messenger component turns a hard‑coded call into a message that can be queued and processed by a worker. The HTTP request simply dispatches the message and returns a response immediately. A background process pulls the message from a transport (e.g., RabbitMQ), builds the email, and sends it via the Mailer.
Key Concepts
- Message: a PHP object that represents an action to be performed later.
- Transport: the underlying broker (RabbitMQ, Amazon SQS, etc.) that stores messages.
- Handler: a service that consumes a message and executes the business logic.
- Worker: a long‑running process that runs
messenger:consumeand pulls messages from the transport.
Step‑by‑Step Example
- Install Dependencies
composer require symfony/messenger symfony/mailer amqp-phpEnsure RabbitMQ is running on
localhost:5672and the defaultguestcredentials are enabled. - Configure Messenger
In
config/packages/messenger.yaml:framework: messenger: transports: async: # name of the transport dsn: amqp://guest:guest@localhost:5672/%2f/messages options: exchange: name: messenger type: direct routing: # Route SendEmailMessage to the async transport 'App\Message\SendEmailMessage': asyncThe
routingsection tells Messenger to sendSendEmailMessageinstances to theasynctransport. - Create the Message Class
Generate a simple DTO that holds the necessary data:
namespace App\Message; class SendEmailMessage { private string $to; private string $subject; private string $body; public function __construct(string $to, string $subject, string $body) { $this->to = $to; $this->subject = $subject; $this->body = $body; } public function getTo(): string { return $this->to; } public function getSubject(): string { return $this->subject; } public function getBody(): string { return $this->body; } } - Implement the Handler
The handler receives the message, builds a
Symfony\Component\Mailer\Message\Email, and dispatches it:namespace App\MessageHandler; use App\Message\SendEmailMessage; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\Message\Email; use Symfony\Messenger\Handler\MessageHandlerInterface; class SendEmailHandler implements MessageHandlerInterface { private MailerInterface $mailer; public function __construct(MailerInterface $mailer) { $this->mailer = $mailer; } public function __invoke(SendEmailMessage $message): void { $email = (new Email()) ->from('[contact removed]') ->to($message->getTo()) ->subject($message->getSubject()) ->html($message->getBody()); $this->mailer->send($email); } }Because the handler is idempotent (it only sends an email), retries will not produce duplicates. If the operation is not naturally idempotent, consider adding an idempotency key or a database flag.
- Dispatch the Message in a Controller
Inject
MessageBusInterfaceand dispatch:namespace App\Controller; use App\Message\SendEmailMessage; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; class ContactController extends AbstractController { public function submit(MessageBusInterface $bus): JsonResponse { // Validate request, extract data $bus->dispatch(new SendEmailMessage( '[contact removed]', 'Contact Form Submission', 'You have a new message...' )); return new JsonResponse(['status' => 'queued']); } } - Run the Worker
In the terminal, start the consumer:
php bin/console messenger:consume asyncMake sure the command runs as the user that owns the Symfony project and has execute permissions on
bin/console. The worker will block, waiting for messages, and process them one by one. - Verify the Flow
After submitting the form, run:
php bin/console messenger:debugCheck the
asynctransport status. In the worker logs, look forSendEmailHandlerentries. If the email is sent successfully, the worker will log the completion and remove the message from the queue.
Trade‑offs and Limitations
- Operational Complexity: You now need a RabbitMQ instance and a process manager (systemd, Supervisor, Docker Compose) to keep the worker alive.
- Retry Logic: Messenger automatically retries failed messages. Ensure your handler is idempotent or guard against duplicates.
- Message Size: Large message payloads increase broker memory usage. Keep only the minimal data required (e.g., email template name and variables) and let the handler fetch heavy resources if needed.
- Latency of Delivery: Emails are no longer guaranteed to be sent before the HTTP response. If the user needs immediate confirmation, consider a hybrid approach.
Next Steps
To scale this setup:
- Deploy multiple workers behind a process manager and use
messenger:consume --limit=50to batch messages. - Configure RabbitMQ clustering for high availability.
- Use
messenger:monitorto watch queue depth and alert on bottlenecks. - Add a monitoring dashboard (e.g., Prometheus + Grafana) to visualize worker health.
By moving email sending to an asynchronous transport, you free your web server to handle more requests, reduce user wait times, and build a more resilient application. The trade‑offs are manageable with a moderate operational overhead and the benefits are significant for any traffic‑heavy site.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.