The API and the Queue

JSON endpoints that expose a chosen subset of each object, and email moved off the request onto a background worker.

Syntax$bus->dispatch(new ArticlePublished($article->getId()));

The capstone now has two more jobs: serve the same articles as JSON, and send the publication email without making the client wait for a mail server.

Groups decide what leaves the application

Serialization is turning an object into a format you can send, here JSON. A serialization group is a named subset of an object's properties: mark a property with #[Groups(['article:read'])] and it is included when that group is requested, left out otherwise. This is how the API returns a title and body but not the author's password hash. The group is passed as context: $this->json($article, 200, [], ['groups' => ['article:read']]).

The queue

Messenger splits background work into three pieces. A message is a plain object carrying data, nothing more. A handler is the class that does the work when the message is processed. A transport is where the message waits in between - a database table, Redis, or an AMQP broker, named by a DSN. Route the message class to a transport and dispatch() returns as soon as the message is stored. A separate process, messenger:consume async, picks it up and calls the handler.

What trips people up

Putting an entity inside a message. The worker is a different process with its own entity manager, so a deserialized entity arrives detached from it. Send the id and load the record in the handler. Dispatch order matters too: dispatch after flush(), or a fast worker can look for a row that has not been committed.

Example

Example · php
<?php
// src/Controller/Api/ArticleApiController.php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Entity\Article;
use App\Message\ArticlePublished;
use App\Security\Voter\ArticleVoter;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/articles')]
final class ArticleApiController extends AbstractController
{
    #[Route('/{id}', name: 'api_article_show', methods: ['GET'])]
    public function show(Article $article): JsonResponse
    {
        // The fourth argument is serializer context, not response headers.
        return $this->json($article, 200, [], ['groups' => ['article:read']]);
    }

    #[Route('/{id}/publish', name: 'api_article_publish', methods: ['POST'])]
    #[IsGranted(ArticleVoter::PUBLISH, 'article')]
    public function publish(
        Article $article,
        EntityManagerInterface $entityManager,
        MessageBusInterface $bus,
        ClockInterface $clock,
    ): JsonResponse {
        $article->publish($clock->now());
        $entityManager->flush();

        // Dispatched after the write, and carrying the id only.
        $bus->dispatch(new ArticlePublished($article->getId()));

        return $this->json($article, 200, [], ['groups' => ['article:read']]);
    }
}

// GET /api/articles/7 returns 200 with every property in the
// article:read group, body included:
// {"id":7,"title":"Shipping day","body":"It went out at nine.",
//  "publishedAt":"2026-01-01T09:00:00+00:00"}
// POST /api/articles/7/publish as an editor returns 200 with the same shape;
// as an author it returns 403 and dispatches nothing.

When to use it

  • Expose an existing entity as JSON without leaking password hashes or internal flags
  • Take a slow email out of a request that currently times out when the mail server is slow
  • Give a failing background job three retries and a failure queue instead of a lost message

More examples

Marking what the API may expose

Two groups, one for reading and one for writing, keep publishedAt out of anything a client can set. An unmarked property is simply absent from grouped output, which is the safe default: adding a column does not silently add a field to the API. Add the groups at the same time as the column, or the next deployment leaks it.

Example · php
<?php
// src/Entity/Article.php (excerpt)

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['article:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    #[Groups(['article:read', 'article:write'])]
    private string $title = '';

    #[ORM\Column(type: 'text')]
    #[Groups(['article:read', 'article:write'])]
    private string $body = '';

    #[ORM\Column(nullable: true)]
    #[Groups(['article:read'])]
    private ?\DateTimeImmutable $publishedAt = null;

    // No group: the author relation is never serialized by accident,
    // which also stops the serializer walking into the User object.
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $author = null;
}

The message and its handler

The message is data with no behaviour, so it serializes cleanly; the handler does the work and is wired by the #[AsMessageHandler] attribute, with no configuration entry to keep in step. The early return matters because a retried message may arrive after the record is gone. This is the shape for any work a user should not wait on: email, thumbnails, webhooks.

Example · php
<?php
// src/Message/ArticlePublished.php

declare(strict_types=1);

namespace App\Message;

final class ArticlePublished
{
    public function __construct(public readonly int $articleId)
    {
    }
}

// src/MessageHandler/ArticlePublishedHandler.php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\ArticlePublished;
use App\Repository\ArticleRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class ArticlePublishedHandler
{
    public function __construct(
        private readonly ArticleRepository $articles,
        private readonly MailerInterface $mailer,
    ) {
    }

    public function __invoke(ArticlePublished $message): void
    {
        $article = $this->articles->find($message->articleId);

        if (null === $article) {
            return; // deleted between dispatch and consume; nothing to send
        }

        $email = (new TemplatedEmail())
            ->from('[email protected]')
            ->to($article->getAuthor()->getEmail())
            ->subject('Your article is live')
            ->htmlTemplate('email/article_published.html.twig')
            ->context(['article' => $article]);

        $this->mailer->send($email);
    }
}

Routing the message and running the worker

A message class with no routing entry is handled synchronously inside the request, which is the single most common reason queued work appears not to be queued. The retry strategy and failure transport mean a mail server outage parks the message instead of losing it. In development the doctrine transport needs no extra service; swap the DSN for Redis or AMQP later without touching the handler.

Example · yaml
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            failed: 'doctrine://default?queue_name=failed'

        routing:
            App\Message\ArticlePublished: async

# .env
# MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0

# Then, in its own process:
#   $ php bin/console messenger:consume async -vv
#   [OK] Consuming messages from transport "async".
#   // The worker will automatically exit once it has received a stop signal.
#   [info] Received message App\Message\ArticlePublished
#   [info] Message App\Message\ArticlePublished handled by App\MessageHandler\ArticlePublishedHandler
#   [info] App\Message\ArticlePublished was handled successfully (acknowledging to transport).

Discussion

  • Be the first to comment on this lesson.