Messages and Handlers

A message is a plain PHP object, a handler is the class that acts on it, and the bus carries one to the other.

Syntaxpublic function dispatch(object $message, array $stamps = []): Envelope

A message is a plain PHP object describing work to be done: SendWelcomeEmail, ResizeAvatar. It extends nothing and implements nothing. It carries data and only data, because it may be stored and read back minutes later by another process.

A handler is the class that does the work. Put the #[AsMessageHandler] attribute on it and type-hint the message on the parameter of __invoke(). That type is what binds handler to message. Older code implemented MessageHandlerInterface; that interface was removed in Symfony 7.0, so on 8.1 the attribute is the only form.

The bus

A message bus is the object you hand a message to. Inject MessageBusInterface and call dispatch(). The bus does not call the handler itself. It passes the message through a stack of middleware, layers that wrap every dispatch, and the last one either runs the handler now or hands the message to a transport: a queue where it waits.

Start on the sync transport

Route the message to sync:// first. Dispatching then runs the handler inside the same request, and an exception surfaces where you dispatched it. Nothing about the message or the handler changes when you later point it at a real queue.

What trips people up

dispatch() returns an Envelope: the message plus stamps, small metadata objects. Passing stamps as a second argument is shorthand. $bus->dispatch($message, [$stamp]) builds the same thing as $bus->dispatch(new Envelope($message, [$stamp])). Neither returns what the handler returned.

Example

Example · php
// src/Message/SendWelcomeEmail.php
declare(strict_types=1);

namespace App\Message;

// A message is data. Keep entities out of it and store the id instead,
// because the object has to survive being serialised into a queue.
final class SendWelcomeEmail
{
    public function __construct(
        public readonly int $userId,
        public readonly string $locale = 'en',
    ) {
    }
}

// src/MessageHandler/SendWelcomeEmailHandler.php
declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SendWelcomeEmail;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
    // Handlers are ordinary services: dependencies arrive through the
    // constructor, type-hinted on their interface.
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    // The parameter type is the only thing that routes the message here.
    public function __invoke(SendWelcomeEmail $message): void
    {
        $this->logger->info('Welcoming user {id}', ['id' => $message->userId]);
    }
}

When to use it

  • Keep the signup response fast by moving the welcome email out of the request.
  • Resize an uploaded image after answering the upload request.
  • Turn an incoming webhook payload into a message so slow processing cannot time out the sender.
  • Split a large import into one message per row so failures are per-row.

More examples

Dispatching from a controller

POST /avatars/7/resize answers 202 with {"queued":true}. The bus arrives as a controller argument because it is a service. The action is identical whether the message is routed to sync:// or to a queue; only where the resizing happens changes.

Example · php
// src/Controller/AvatarController.php
declare(strict_types=1);

namespace App\Controller;

use App\Message\ResizeAvatar;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

final class AvatarController extends AbstractController
{
    #[Route('/avatars/{id}/resize', name: 'app_avatar_resize', methods: ['POST'])]
    public function resize(int $id, MessageBusInterface $bus): JsonResponse
    {
        $bus->dispatch(new ResizeAvatar($id, 256));

        return $this->json(['queued' => true], Response::HTTP_ACCEPTED);
    }
}

Stamps: shorthand and longhand

Two ways to write one dispatch. You will see both in other people's code, so recognise them as the same call. Write the short form unless you already hold an Envelope you want to add to.

Example · php
// src/Service/ReportDispatcher.php
declare(strict_types=1);

namespace App\Service;

use App\Message\BuildReport;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;

final class ReportDispatcher
{
    public function __construct(private readonly MessageBusInterface $bus)
    {
    }

    public function dispatchShorthand(int $reportId): void
    {
        // Stamps as the second argument.
        $this->bus->dispatch(new BuildReport($reportId), [new DelayStamp(5000)]);
    }

    public function dispatchLonghand(int $reportId): void
    {
        // The same call written out: dispatch() wraps the message in exactly
        // this Envelope when you use the short form.
        $this->bus->dispatch(new Envelope(new BuildReport($reportId), [new DelayStamp(5000)]));
    }
}

Routing the message to sync while you build

With this routing the handler runs during the request that dispatched it, so you can develop and debug without a worker process. A message class with no routing entry is also handled immediately. Changing 'sync' to a queue transport later needs no code change.

Example · yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            sync: 'sync://'
        routing:
            App\Message\SendWelcomeEmail: sync

Discussion

  • Be the first to comment on this lesson.