The basic idea

Symfony Messenger: Making Slow Work Feel Fast

phpsymfonymessengerasync
Symfony Messenger: Making Slow Work Feel Fast

Source: https://dribbble.com/shots/6521459-The-Visual-Learner-s-Guide-to-Async-JS

A web request should usually do only the work necessary to produce its response. Sending emails, recalculating commissions, generating reports, importing data, or communicating with third-party APIs can all take seconds - or fail temporarily.

Symfony Messenger lets us move that work outside the request.

The user receives a quick response while a background worker completes the longer operation.

Symfony describes Favicon of url symfony.comMessenger as a message bus that can either handle messages immediately or send them through a transport to be handled later.

The basic idea

Instead of calling a slow service directly:

php
1$this->emailManager->send();
2
3$this->calculationService->recalculateEverything();

We create a message describing the work:

php
1final class SendNotificationEmail
2{
3    public function __construct(
4        public int $userId,
5        public string $title,
6        public string $note,
7        public string $url,
8    ) {
9    }
10}

Then we dispatch it:

php
1$this->bus->dispatch(new SendNotificationEmail(
2    userId: $user->getId(),
3    title: $title,
4    note: $note,
5    url: $url,
6));

The controller or application service can now finish quickly. A worker will later receive the message and invoke its handler.

php
1#[AsMessageHandler]
2final readonly class SendNotificationEmailHandler
3{
4    public function __construct(
5        private UserRepository $users,
6        private EmailManager $emailManager,
7    ) {
8    }
9
10    public function __invoke(SendNotificationEmail $message): void
11    {
12        $user = $this->users->find($message->userId);
13
14        if ($user === null) {
15            return;
16        }
17
18        $this->emailManager
19            ->setRecipient(new Address($user->getEmail()))
20            ->setSubject($message->title)
21            ->setHtmlBody('email/notification.html.twig', [
22                'title' => $message->title,
23                'note' => $message->note,
24                'url' => $message->url,
25            ])
26            ->send();
27    }
28}

The important distinction is:

dispatch()       = put work on the bus or queue
handler          = perform the work
worker           = continuously receive and execute messages
transport       = storage or delivery mechanism for queued messages

How this project uses Messenger

This project configures an asynchronous transport:

yaml
1# messenger.yaml
2
3framework:
4    messenger:
5        transports:
6            async: '%env(MESSENGER_DSN)%'
7        routing:
8            'App\Message\NotificationEmailMessage': async

Both message classes are routed to async.

That means this call:

php
1$this->bus->dispatch(new NotificationEmailMessage($notificationType, $title, $note, $url, $user));

does not send the email immediately. It places the message onto the configured transport.

The handler later performs the actual operation:

php
1#[AsMessageHandler]
2final readonly class NotificationEmailMessageHandler
3{
4    public function __invoke(NotificationEmailMessage $message): void
5    {
6        $this->manager
7            ->setRecipient(new Address($message->user->getEmail()))
8            ->setSubject($message->title)
9            ->setHtmlBody('email/notification.html.twig', [
10                'title' => $message->title,
11                'note' => $message->note,
12                'url' => $message->url,
13            ])
14            ->send();    // <--- SEND THE EMAIL ONE BY ONE
15    }
16}

A worker must be running:

bash
1$ php bin/console messenger:consume async

In production, this command is normally managed by Supervisor, systemd, Kubernetes, or another process manager.

Why the process feels quick

Imagine that sending an email takes 800 milliseconds and a calculation takes 10 seconds.

Without Messenger:

Browser request
    ├── calculate data: **10 seconds**
    ├── send email: **800 ms**
    └── response after approximately **10.8 seconds**

With Messenger:

Browser request
    ├── create message
    ├── enqueue message
    └── response after approximately **50 ms**
Background worker
    ├── receive message
    ├── calculate or send email
    └── acknowledge message

The work has not disappeared. It has been moved to a different execution context.

This gives the user a fast response and allows the application to continue processing the operation independently.

It also prevents a slow external service from keeping an HTTP connection open. The browser does not need to wait while an SMTP server, payment provider, API, or large calculation finishes.

Messenger is more than “run this later”

Messenger provides infrastructure around the message:

  • routing messages to transports
  • Doctrine, Redis, AMQP, Amazon SQS, and other transport options
  • workers for consuming queued messages
  • retry strategies
  • delayed messages
  • failure transports
  • middleware
  • message stamps
  • multiple buses and priorities
  • rate limiting
  • logging and worker lifecycle events

For example, retry configuration can be added like this:

yaml
1framework:
2    messenger:
3        failure_transport: failed
4
5        transports:
6            async:
7                dsn: '%env(MESSENGER_DSN)%'
8                retry_strategy:
9                    max_retries: 5
10                    delay: 1000
11                    multiplier: 2
12                    max_delay: 60000
13
14            failed: 'doctrine://default?queue_name=failed'

A temporary SMTP or API failure might produce this sequence:

Attempt 1:     fails
Wait 1 second
Attempt 2:     fails
Wait 2 seconds
Attempt 3:     fails
Wait 4 seconds
Attempt 4:     succeeds

If all attempts fail, the message can be stored in a failure transport for inspection and manual retry:

bash
1$ php bin/console messenger:failed:show
2$ php bin/console messenger:failed:retry 20 --force

Symfony’s default retry behavior is configurable, and failed messages can be placed in a dedicated failure transport instead of being lost. Symfony retries and failure transports

A subtle issue: dispatching is not completion

This is important:

php
1$this->bus->dispatch($message);

does not mean that the work succeeded.

It normally means that Messenger accepted the message for delivery. The actual handler may:

  • execute later
  • fail
  • be retried
  • be delayed
  • be moved to a failure queue
  • be processed by another machine

If the user needs to know whether the operation eventually succeeded, the application should track its status.

For example:

php
1final class RecalculationStatus
2{
3    public const RUNNING = 'running';
4    public const COMPLETED = 'completed';
5    public const FAILED = 'failed';
6}

The frontend can then poll a status endpoint or receive a notification when the work finishes.

Another subtle issue: database timing

In the calculation command, messages are dispatched before a later flush():

php
1$this->bus->dispatch(new RegenerateCommissionsAndRenewalsMessage(...));
2
3// Later:
4$this->em->flush();

If the transport is handled by another process, the worker may start before the command has flushed its database changes.

That can create a race condition:

Command process                                Worker process
dispatch message      --------------------►receives message
                                                              loads old database state
flush changes

A safer design is usually one of these:

php
1// Persist important changes first.
2$this->em->flush();
3
4// Then dispatch work that depends on those changes.
5$this->bus->dispatch($message);

Or use Messenger’s Doctrine transaction middleware and an “after current bus” strategy where appropriate.

Messages should also generally contain stable identifiers rather than Doctrine entities:

php
1// Prefer:
2new NotificationEmailMessage(
3    userId: $user->getId(),
4    ...
5);
6
7// Be cautious with:
8new NotificationEmailMessage(
9    user: $user,
10    ...
11);

Serialized entities can become stale, contain more data than necessary, or cause problems when the message is retried after a deployment. The handler can reload the current entity by ID.

Messenger versus native asynchronous features

The phrase “async” means different things in different technologies.

Symfony Messenger

Symfony Messenger is primarily an application-level messaging and job-processing system.

It provides:

message → transport/queue → worker → handler

Its strengths are durability, retries, delayed delivery, failure handling, and integration with databases and message brokers.

It can also operate synchronously. Without an asynchronous transport, the handler can run immediately during the current request.

JavaScript promises and async/await

JavaScript code often uses:

javascript
1await fetch('/api/report');

This is useful for non-blocking I/O within a running Favicon of url nodejs.orgNode.js process or browser application.

However, a Promise is not automatically a durable job. If the Node.js process crashes, the pending operation may disappear. A Promise also does not automatically provide a retry queue, failure storage, or a separate worker process.

Messenger is closer to a durable job queue than to Promise.

Final words

Symfony Messenger is useful when work does not need to finish during the original HTTP request. Sending emails, processing uploaded files, generating reports, resizing images, calling external services, and handling integrations are common examples. Moving these tasks to a message queue makes the application faster for users and more resilient when external services are temporarily unavailable.

Messenger should be used when:

  • the task is slow or resource-intensive
  • the task can safely run asynchronously
  • failures should be retried
  • processing should continue even after the user leaves the page
  • work needs to be distributed across multiple workers
  • traffic may arrive in bursts and should be processed gradually

It is not necessary for every operation. Simple, quick database updates are usually better handled synchronously. Introducing a queue adds operational complexity, so it should be reserved for work that benefits from asynchronous processing, retries, prioritization, or independent scaling.

— Lang