Why background jobs matter in Symfony apps
Most Symfony applications start with a simple pattern: the user sends a request, the controller does the work, and the response comes back. That works for reading a product page. It falls apart when you send invoices, resize images, call external APIs, or sync data with a warehouse system.
If those tasks stay inside the HTTP request, users wait longer, timeouts appear, and one slow dependency can take down the whole checkout flow. Symfony Messenger solves this by letting you dispatch a message now and process it later, in a worker that runs outside the web request.
What Symfony Messenger actually does
Messenger is a message bus. Your application creates a small object that describes work to do, then sends that object into the bus. A transport (usually Redis, Doctrine, or RabbitMQ) stores the message. A separate console worker consumes the queue and runs a handler.
That split gives you three practical wins:
- Faster responses for end users.
- Automatic retries when a third party fails temporarily.
- Clear separation between “accept the work” and “finish the work”.
A minimal example
Start with a message that carries only the data the handler needs:
namespace App\Message;
final class SendOrderConfirmation
{
public function __construct(
public readonly int $orderId,
) {
}
}
Then write a handler that does the real work:
namespace App\MessageHandler;
use App\Message\SendOrderConfirmation;
use App\Repository\OrderRepository;
use App\Service\Mailer\OrderMailer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendOrderConfirmationHandler
{
public function __construct(
private OrderRepository $orders,
private OrderMailer $mailer,
) {
}
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->get($message->orderId);
$this->mailer->sendConfirmation($order);
}
}
In a controller or service, dispatch instead of sending mail directly:
use App\Message\SendOrderConfirmation;
use Symfony\Component\Messenger\MessageBusInterface;
public function placeOrder(MessageBusInterface $bus, int $orderId): void
{
// Persist the order first, then queue side effects.
$bus->dispatch(new SendOrderConfirmation($orderId));
}
Configure an async transport
In config/packages/messenger.yaml, route heavy messages to a real queue:
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 10000
failed: 'doctrine://default?queue_name=failed'
routing:
'App\Message\SendOrderConfirmation': async
A typical Redis DSN looks like redis://localhost:6379/messages. For teams already on MySQL or PostgreSQL, the Doctrine transport is a fine starting point. Move to RabbitMQ or Redis when volume grows.
Run workers the right way
Workers are long lived processes. Start them with Supervisor, systemd, or your container orchestrator:
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
--time-limit and --memory-limit force a clean restart before memory leaks become production incidents. After every deploy, restart workers so they load the new code.
Retries, failures, and observability
Temporary network errors should retry. Permanent errors should stop. Messenger already supports exponential backoff through retry_strategy. Messages that still fail land in the failure transport, where you can inspect and retry them:
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
Add structured logs inside handlers. Log the message class, entity id, and attempt number. That makes queue incidents much easier to diagnose than a generic “mail failed” entry.
Design rules that keep Messenger maintainable
Keep messages small and serializable. Prefer ids over full entity graphs. Large objects in the queue create versioning pain when your domain model changes.
Make handlers idempotent. A confirmation email should not be sent twice just because a worker crashed after success but before acknowledging the message. Store a “confirmation sent” flag or use a unique provider message id.
Dispatch after the database commits. If you queue a job and then the transaction rolls back, the worker may process an order that never existed. Use Doctrine middleware or dispatch in an onFlush / post commit listener when consistency matters.
One handler, one responsibility. A message named SendOrderConfirmation should send confirmation mail. Creating invoices, updating stock, and notifying CRM can be separate messages triggered from the same domain event.
When Messenger is the wrong tool
Not every slow operation belongs in a queue. If the user must see the result immediately (payment confirmation screen, live validation), keep the work synchronous and optimize it. Messenger shines when the business accepts a short delay in exchange for reliability.
Conclusion
Symfony Messenger is one of the highest leverage tools in a modern Symfony stack. It protects response times, absorbs flaky integrations, and turns side effects into explicit, testable units of work. If your PHP application still sends mail, generates PDFs, or calls third party APIs inside controllers, moving that work to Messenger is usually the fastest path to a more stable production system.