Moving Side Effects Out of Requests with Symfony Messenger
Decouple slow side effects from HTTP requests by dispatching intents with Symfony Messenger, configuring an async transport, and processing them with supervised workers and retry middleware.
15 Jan 2026, 00:29 UTC

The request that should not send email
A signup form that creates a user and sends a welcome email in the same HTTP request feels simple until the mail provider is slow or rate-limited. The user waits, the request times out, and the business logic is coupled to an unreliable side effect. The useful takeaway is to dispatch an intent and return immediately, letting a worker handle the side effect later.
Symfony Messenger provides a message bus and configurable transports for exactly this decoupling. The bus routes a message object to one or more handlers. A transport holds messages until a worker consumes them. Middleware wraps the bus to add retry, logging, or rate limiting without changing handlers.
Transport and bus configuration
Messenger needs a transport for asynchronous work. Doctrine transport stores messages in a database table and is convenient for local development. RabbitMQ or Redis are common for production.
Install the Messenger component and a transport bundle, then define the transport in config/packages/messenger.yaml. The DSN is a placeholder for your actual connection string.
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Message\SendWelcomeEmail': async
failure_transport: failed
The routing entry sends SendWelcomeEmail messages to the async transport. The failure_transport captures messages that exhaust retries. Run messenger:setup-transports from the project root with write permissions to create the messenger_messages table for Doctrine transport. This changes database schema, so run it in a maintenance window.
Middleware is added in the same config file. Retry middleware is useful for transient mail errors.
framework:
messenger:
buses:
command_bus:
middleware:
- 'Symfony\Component\Messenger\Middleware\HandleMessageMiddleware'
A minimal SendWelcomeEmail flow
Define a value object for the intent. Keep it serializable and free of services.
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly string $userId,
public readonly string $email,
) {}
}
A handler contains the side effect. The service container autowires it, so no manual registration is needed.
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Component\Mailer\MailerInterface;
final class SendWelcomeEmailHandler
{
public function __construct(private MailerInterface $mailer) {}
public function __invoke(SendWelcomeEmail $message): void
{
// build and send email using $message->email
}
}
The controller dispatches without waiting for the mail to be sent.
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId(), $user->getEmail()));
return new Response('Created');
Start a worker from the project root. The worker requires read/write access to the transport and the ability to run long-lived processes.
bin/console messenger:consume async --time-limit=3600
The command blocks and pulls messages from the async transport. A practical way to check that dispatch is asynchronous is to query the transport store after dispatching and before the worker runs. With Doctrine transport you can inspect messenger_messages for a row with the message class. After the worker processes it, the row should be removed.
Reliability and failure modes
Asynchronous processing introduces eventual consistency. The caller does not know if the email was sent, only that the intent was accepted. Design the UI to reflect pending work and avoid assuming immediate delivery.
Retry middleware can be configured with max retries and delay. Messages that fail permanently move to the failure transport. Inspect failed messages with bin/console messenger:failed:show and retry with messenger:failed:retry. This requires permission to access the failure transport.
Rate limiting middleware helps avoid throttling by external providers. Logging middleware provides an audit trail without polluting handlers.
Trade-off to keep in mind
Moving work out of the request improves response time and resilience, but it adds operational complexity. Workers must be supervised, monitored, and scaled. Message ordering is not guaranteed across multiple workers. If strict ordering is required, use a single worker or a partitioned transport.
Do not use Messenger for operations that must succeed synchronously, such as validating a payment before returning success to the user.
Actionable next steps
Pick one side effect that slows requests, such as welcome emails or report generation. Create a message class, a handler, and route it to an async transport. Add retry and failure transport configuration from the start. Run a worker in a development environment and verify that dispatch returns quickly while the handler processes later. Monitor the failure queue and set alerts for growing backlog.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.