Módulo 11: Eventos, Messenger y Mailer

Objetivos del módulo

  • Entender el patrón observador y el componente EventDispatcher
  • Conocer los eventos del kernel y en qué momento de la petición se lanzan
  • Escribir listeners con #[AsEventListener] y subscribers con EventSubscriberInterface
  • Crear y lanzar eventos propios (ArticlePublishedEvent)
  • Reaccionar a eventos de Doctrine con #[AsEntityListener] y #[AsDoctrineListener]
  • Enviar emails con Mailer, plantillas Twig y Mailpit en desarrollo
  • Procesar tareas en segundo plano con Messenger: mensajes, handlers, transportes, workers, reintentos y fallos
  • Construir el flujo completo: al publicar un artículo, avisar por email a los suscriptores de forma asíncrona
  • Conocer el componente Notifier

1. Eventos: el patrón observador

Cuando publicas un artículo pueden tener que pasar muchas cosas: avisar a los suscriptores, limpiar una caché, publicar en redes sociales, registrar una estadística… Si ArticlePublisher (módulo 09) hiciera todo eso directamente, acabaría dependiendo de media aplicación.

📘 Concepto: El patrón observador desacopla “lo que ocurre” de “quién reacciona”. Un objeto anuncia un evento (“se ha publicado un artículo”) y otros objetos, los listeners, que se han suscrito a ese evento, reaccionan. Quien lanza el evento no sabe quién escucha ni cuántos son.

Piensa en la megafonía de una estación: el jefe de estación anuncia “el tren a Valencia sale por la vía 3”. No sabe quién está escuchando; los viajeros de ese tren se mueven, el de la cafetería prepara bocadillos y el resto sigue a lo suyo.

flowchart LR
    P[ArticlePublisher] -->|dispatch| D((EventDispatcher))
    D --> L1[Avisar suscriptores]
    D --> L2[Limpiar caché de portada]
    D --> L3[Registrar estadística]

En Symfony, el componente EventDispatcher es el “megáfono”. Y lo usa el propio framework: cada petición HTTP dispara una serie de eventos a los que puedes engancharte.


2. Eventos del kernel

flowchart TB
    A[Petición HTTP] --> R[kernel.request]
    R -->|un listener devuelve Response| RESP
    R --> C[kernel.controller]
    C --> CA[kernel.controller_arguments]
    CA --> X[Ejecución del controlador]
    X -->|devuelve Response| RESP[kernel.response]
    X -->|no devuelve Response| V[kernel.view]
    V --> RESP
    RESP --> F[kernel.finish_request]
    F --> S[Respuesta enviada al navegador]
    S --> T[kernel.terminate]
    X -. excepción .-> E[kernel.exception]
    E --> RESP
Evento Constante Clase del evento Uso típico
kernel.request KernelEvents::REQUEST RequestEvent Modo mantenimiento, idioma, redirecciones tempranas
kernel.controller KernelEvents::CONTROLLER ControllerEvent Cambiar o inspeccionar el controlador que se ejecutará
kernel.controller_arguments KernelEvents::CONTROLLER_ARGUMENTS ControllerArgumentsEvent Modificar los argumentos del controlador
kernel.view KernelEvents::VIEW ViewEvent Convertir en Response lo que devuelve el controlador si no es una
kernel.response KernelEvents::RESPONSE ResponseEvent Añadir cabeceras, modificar el contenido
kernel.finish_request KernelEvents::FINISH_REQUEST FinishRequestEvent Restaurar estado tras subpeticiones
kernel.terminate KernelEvents::TERMINATE TerminateEvent Tareas lentas después de enviar la respuesta
kernel.exception KernelEvents::EXCEPTION ExceptionEvent Páginas o JSON de error personalizados

💡 Consejo: Para ver todos los listeners registrados y su prioridad usa php bin/console debug:event-dispatcher o filtra por uno: php bin/console debug:event-dispatcher kernel.request. Te sorprenderá cuántos usa ya Symfony (enrutado, seguridad, sesión, Profiler…).


3. Listeners con #[AsEventListener]

3.1 Modo mantenimiento (kernel.request)

<?php
// src/EventListener/MaintenanceListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;

#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
final readonly class MaintenanceListener
{
    public function __construct(
        #[Autowire(env: 'bool:BLOG_MAINTENANCE')]
        private bool $maintenance,
        private Environment $twig,
    ) {
    }

    public function __invoke(RequestEvent $event): void
    {
        // Ignora subpeticiones (fragmentos, ESI)
        if (!$this->maintenance || !$event->isMainRequest()) {
            return;
        }

        // El panel de administración sigue accesible
        if (str_starts_with($event->getRequest()->getPathInfo(), '/admin')) {
            return;
        }

        // Al asignar una respuesta, Symfony no ejecuta el controlador
        $event->setResponse(new Response(
            $this->twig->render('maintenance.html.twig'),
            Response::HTTP_SERVICE_UNAVAILABLE,
        ));
    }
}
Parte Significado
#[AsEventListener(event: ...)] Registra la clase como listener de ese evento (gracias a la autoconfiguración)
priority: 20 Mayor prioridad = se ejecuta antes (por defecto 0; puede ser negativa)
__invoke() Método llamado si no indicas method:
$event->isMainRequest() true en la petición principal, false en subpeticiones
$event->setResponse() En kernel.request cortocircuita la petición

3.2 Errores JSON para la API (kernel.exception)

<?php
// src/EventListener/ApiExceptionListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::EXCEPTION)]
final class ApiExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        if (!str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
            return; // el resto de la web usa las páginas de error normales
        }

        $exception = $event->getThrowable();
        $status = $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500;

        $event->setResponse(new JsonResponse([
            'error' => $status === 500 ? 'Error interno' : $exception->getMessage(),
            'status' => $status,
        ], $status));
    }
}

3.3 Varios eventos en una clase

#[AsEventListener] también se puede poner sobre métodos:

<?php
// src/EventListener/ResponseHeadersListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Psr\Log\LoggerInterface;

final readonly class ResponseHeadersListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    #[AsEventListener(event: KernelEvents::RESPONSE)]
    public function addSecurityHeaders(ResponseEvent $event): void
    {
        $headers = $event->getResponse()->headers;
        $headers->set('X-Content-Type-Options', 'nosniff');
        $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
    }

    #[AsEventListener(event: KernelEvents::TERMINATE)]
    public function logSlowRequests(TerminateEvent $event): void
    {
        $start = $event->getRequest()->server->get('REQUEST_TIME_FLOAT');
        $ms = (microtime(true) - $start) * 1000;

        if ($ms > 500) {
            $this->logger->warning('Petición lenta', ['uri' => $event->getRequest()->getUri(), 'ms' => (int) $ms]);
        }
    }
}

💡 Consejo: php bin/console make:listener te genera el esqueleto: te pregunta el evento (puedes escribir kernel.response o el nombre de la clase) y crea la clase con el método y el tipo correctos.


4. Subscribers

Un subscriber es una alternativa: la propia clase declara a qué eventos se suscribe implementando EventSubscriberInterface.

<?php
// src/EventSubscriber/LocaleSubscriber.php
declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class LocaleSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            // evento => [método, prioridad]
            KernelEvents::REQUEST => ['onKernelRequest', 20],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // ?lang=en cambia el idioma y lo guarda en sesión
        if ($lang = $request->query->get('lang')) {
            $request->getSession()->set('_locale', $lang);
        }

        $request->setLocale($request->getSession()->get('_locale', 'es'));
    }
}
  Listener (#[AsEventListener]) Subscriber (EventSubscriberInterface)
Dónde se declara el evento Atributo sobre la clase o el método Método estático getSubscribedEvents()
Varios eventos en una clase Sí (un atributo por método) Sí
Desactivar/configurar desde YAML Más fácil Más difícil (el evento está en el código)
Estilo Moderno, recomendado en la documentación actual Clásico, muy usado en bundles

📘 Concepto: Los dos son equivalentes en funcionalidad. En este curso usaremos #[AsEventListener] por coherencia con el resto de atributos, pero encontrarás muchos subscribers en proyectos y bundles existentes.

Detener la propagación

Un listener puede impedir que se ejecuten los siguientes (los de menor prioridad):

$event->stopPropagation();

Úsalo con moderación: hace que el comportamiento dependa del orden de los listeners.


5. Eventos propios: ArticlePublishedEvent

5.1 La clase del evento

<?php
// src/Event/ArticlePublishedEvent.php
declare(strict_types=1);

namespace App\Event;

use App\Entity\Article;
use Symfony\Contracts\EventDispatcher\Event;

final class ArticlePublishedEvent extends Event
{
    public function __construct(
        public readonly Article $article,
        public readonly bool $notifySubscribers = true,
    ) {
    }
}

📘 Concepto: Un evento es un objeto sencillo que transporta información sobre lo que ha pasado. Se nombra en pasado (ArticlePublished, CommentPosted, UserRegistered): describe un hecho consumado.

5.2 Lanzarlo desde el servicio

Ampliamos ArticlePublisher del módulo 09:

<?php
// src/Service/ArticlePublisher.php
declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use App\Event\ArticlePublishedEvent;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;

final readonly class ArticlePublisher
{
    public function __construct(
        private EntityManagerInterface $em,
        private SlugGenerator $slugGenerator,
        private EventDispatcherInterface $dispatcher,
    ) {
    }

    public function publish(Article $article, bool $notifySubscribers = true): void
    {
        if ($article->getPublishedAt() !== null) {
            throw new \LogicException('El artículo ya está publicado.');
        }

        $article->setSlug($article->getSlug() ?? $this->slugGenerator->generate($article->getTitle()));
        $article->setPublishedAt(new \DateTimeImmutable());
        $this->em->flush();

        // Se lanza DESPUÉS del flush: el artículo ya está guardado
        $this->dispatcher->dispatch(new ArticlePublishedEvent($article, $notifySubscribers));
    }
}

Si no indicas nombre, el evento se identifica por su clase (App\Event\ArticlePublishedEvent).

5.3 Escucharlo

<?php
// src/EventListener/ClearHomepageCacheListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Event\ArticlePublishedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\Cache\CacheInterface;

#[AsEventListener(event: ArticlePublishedEvent::class)]
final readonly class ClearHomepageCacheListener
{
    public function __construct(private CacheInterface $cache)
    {
    }

    public function __invoke(ArticlePublishedEvent $event): void
    {
        // La portada cacheada ya no es válida
        $this->cache->delete('homepage_latest_articles');
    }
}

Más adelante, en la sección 9, añadiremos un segundo listener al mismo evento que avisa a los suscriptores… sin tocar ArticlePublisher.


6. Eventos de Doctrine

Doctrine tiene su propio sistema de eventos, ligado al ciclo de vida de las entidades.

Evento Cuándo se lanza
prePersist Antes de insertar una entidad nueva (al hacer persist())
postPersist Después del INSERT (ya tiene id)
preUpdate Antes del UPDATE de una entidad modificada
postUpdate Después del UPDATE
preRemove / postRemove Antes / después del DELETE
postLoad Al cargar una entidad desde la BD
preFlush / onFlush / postFlush Alrededor de cada flush()

6.1 Callbacks del ciclo de vida

Para lógica sencilla que solo afecta a la propia entidad, usa lifecycle callbacks:

// src/Entity/Article.php (fragmento)
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    #[ORM\PrePersist]
    public function initCreatedAt(): void
    {
        $this->createdAt ??= new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function touchUpdatedAt(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }
}

6.2 Entity listener: necesita servicios

Si la lógica necesita un servicio (como SlugGenerator), usa un entity listener, que es un servicio normal con autowiring:

<?php
// src/EventListener/ArticleSlugListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Entity\Article;
use App\Service\SlugGenerator;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Events;

#[AsEntityListener(event: Events::prePersist, method: 'prePersist', entity: Article::class)]
final readonly class ArticleSlugListener
{
    public function __construct(private SlugGenerator $slugGenerator)
    {
    }

    public function prePersist(Article $article, PrePersistEventArgs $args): void
    {
        if ($article->getSlug() === null || $article->getSlug() === '') {
            $article->setSlug($this->slugGenerator->generate($article->getTitle()));
        }
    }
}

Ahora ningún controlador tiene que acordarse de generar el slug: cualquier artículo nuevo lo recibe al persistirse.

6.3 Doctrine listener: todas las entidades

#[AsDoctrineListener] escucha un evento para todas las entidades; tú filtras dentro:

<?php
// src/EventListener/AuditLogListener.php
declare(strict_types=1);

namespace App\EventListener;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Events;
use Psr\Log\LoggerInterface;

#[AsDoctrineListener(event: Events::postRemove)]
final readonly class AuditLogListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function postRemove(PostRemoveEventArgs $args): void
    {
        $entity = $args->getObject();

        $this->logger->notice('Entidad eliminada', ['class' => $entity::class]);
    }
}
Herramienta Alcance ¿Servicios? Cuándo
#[ORM\PrePersist] (callback) Una entidad, dentro de la clase No Fechas, valores por defecto
#[AsEntityListener] Una entidad Sí Lógica de una entidad que necesita servicios
#[AsDoctrineListener] Todas las entidades Sí Auditoría, soft delete, multi-tenant

⚠️ Importante: No llames a flush() dentro de un listener de Doctrine: provocarías bucles o comportamientos inesperados. Y en preUpdate solo se aplican los cambios hechos a campos que ya estaban modificados (consulta $args->hasChangedField('title')). Para acciones “de negocio” (enviar un email al publicar) prefiere eventos propios como ArticlePublishedEvent: expresan intención, no un detalle de persistencia.


7. Mailer: enviar emails

7.1 Instalación y configuración

composer require symfony/mailer

La conexión se define con una variable de entorno, MAILER_DSN:

# .env
MAILER_DSN=null://null

# .env.local (desarrollo con Mailpit)
MAILER_DSN=smtp://localhost:1025

# Producción (ejemplos)
# MAILER_DSN=smtp://usuario:contraseña@smtp.tu-proveedor.com:587
# MAILER_DSN=sendgrid://CLAVE@default   (requiere symfony/sendgrid-mailer)
DSN Significado
null://null No envía nada (útil por defecto y en tests)
smtp://host:puerto Servidor SMTP
sendmail://default Binario sendmail local
proveedor://... Proveedores como Mailgun, Postmark, Amazon SES, Brevo… (cada uno con su paquete)

7.2 Mailpit con Docker

En desarrollo no quieres enviar emails reales. Mailpit es un servidor SMTP falso que captura todos los correos y te los muestra en una web.

docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
  • SMTP en localhost:1025 (lo que pones en MAILER_DSN)
  • Bandeja web en http://localhost:8025

Si usas Docker Compose, la receta de Symfony Mailer añade un servicio parecido a compose.override.yaml:

# compose.override.yaml
services:
  mailer:
    image: axllent/mailpit
    ports:
      - "1025"
      - "8025"
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1

💡 Consejo: Si arrancas los contenedores con docker compose up -d y el servidor con symfony serve, la Symfony CLI detecta el servicio mailer y define MAILER_DSN automáticamente. Con symfony open:local:webmail abres la bandeja de Mailpit.

7.3 Un email sencillo

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;

public function __construct(private MailerInterface $mailer) {}

public function sendTest(): void
{
    $email = new Email()
        ->from(new Address('no-reply@blog.test', 'Blog de Symfony'))
        ->to('lector@example.com')
        ->subject('¡Hola desde el blog!')
        ->text('Este es el contenido en texto plano.')
        ->html('<p>Este es el contenido en <strong>HTML</strong>.</p>');

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

Para comprobar la configuración rápidamente:

php bin/console mailer:test lector@example.com

7.4 TemplatedEmail: emails con Twig

use Symfony\Bridge\Twig\Mime\TemplatedEmail;

$email = new TemplatedEmail()
    ->from(new Address('no-reply@blog.test', 'Blog de Symfony'))
    ->to(new Address($subscriber->getEmail()))
    ->subject('Nuevo artículo: '.$article->getTitle())
    ->htmlTemplate('emails/new_article.html.twig')
    ->textTemplate('emails/new_article.txt.twig')
    ->context([
        'article' => $article,
        'unsubscribeToken' => $subscriber->getToken(),
    ]);

$this->mailer->send($email);
{# templates/emails/new_article.html.twig #}
<h1>{{ article.title }}</h1>

<p>Hola, {{ email.to[0].address }}:</p>

<p>Acabamos de publicar un nuevo artículo en la categoría
   <strong>{{ article.category.name }}</strong>.</p>

<p>{{ article.content|striptags|u.truncate(250, '...') }}</p>

<p>
    <a href="{{ url('app_article_show', {slug: article.slug}) }}"
       style="background:#0d6efd;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none">
        Leer el artículo
    </a>
</p>

<p style="font-size:12px;color:#6c757d">
    <a href="{{ url('app_unsubscribe', {token: unsubscribeToken}) }}">Darme de baja</a>
</p>

⚠️ Importante: En los emails usa siempre url() (URL absoluta) y no path() (URL relativa): un enlace relativo no funciona dentro de un cliente de correo. Si el email se envía desde un comando o un worker (sin petición HTTP), configura la URL base en framework.router.default_uri (por ejemplo, https://blog.test).

💡 Consejo: La variable especial email da acceso al mensaje en la plantilla (email.subject, email.to…) y permite incrustar imágenes con email.image('@images/logo.png'). El filtro u.truncate requiere twig/string-extra; para estilos complejos existe el filtro inline_css (twig/cssinliner-extra).

7.5 Configuración global

# config/packages/mailer.yaml
framework:
    mailer:
        dsn: '%env(MAILER_DSN)%'
        envelope:
            sender: 'no-reply@blog.test'
        headers:
            From: 'Blog de Symfony <no-reply@blog.test>'

when@dev:
    framework:
        mailer:
            envelope:
                # en dev, todos los emails van a tu dirección
                recipients: ['tu-email@example.com']

8. Messenger: tareas en segundo plano

8.1 El problema

Enviar un email tarda entre 100 ms y varios segundos. Si al publicar un artículo enviamos 500 emails durante la petición, el administrador esperará minutos y, si el SMTP falla a mitad, se perderán los envíos.

📘 Concepto: Messenger permite enviar mensajes (objetos que describen una tarea) a un bus. El bus los entrega a su handler, ya sea en el momento (síncrono) o guardándolos en una cola (transporte) para que un proceso aparte, el worker, los ejecute más tarde (asíncrono).

Es como dejar los pedidos en el mostrador de una tintorería: tú te vas inmediatamente con tu resguardo y el personal los procesa en la trastienda a su ritmo. Si una máquina falla, lo vuelven a intentar.

flowchart LR
    C[Controlador] -->|dispatch mensaje| B((Bus))
    B -->|routing: sync| H1[Handler]
    B -->|routing: async| Q[("Transporte: cola en BD o Redis")]
    Q --> W["Worker: messenger:consume"]
    W --> H2[Handler]
    W -->|falla 3 veces| F[(Transporte failed)]

8.2 Instalación

composer require symfony/messenger

En un proyecto --webapp ya está instalado con el transporte de Doctrine:

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

8.3 Mensaje y handler

Un mensaje es una clase simple (DTO) que contiene los datos mínimos para hacer la tarea:

<?php
// src/Message/SendNewArticleNotification.php
declare(strict_types=1);

namespace App\Message;

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

⚠️ Importante: En los mensajes asíncronos pasa identificadores, no entidades. El mensaje se serializa y puede procesarse minutos después; una entidad serializada estaría desconectada de Doctrine y desactualizada. El handler volverá a cargarla desde la base de datos.

El handler contiene la lógica:

<?php
// src/MessageHandler/SendNewArticleNotificationHandler.php
declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SendNewArticleNotification;
use App\Repository\ArticleRepository;
use App\Repository\SubscriberRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Mime\Address;

#[AsMessageHandler]
final readonly class SendNewArticleNotificationHandler
{
    public function __construct(
        private ArticleRepository $articles,
        private SubscriberRepository $subscribers,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
    ) {
    }

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

        if ($article === null) {
            // Reintentar no tiene sentido: el artículo ya no existe
            throw new UnrecoverableMessageHandlingException(sprintf('Artículo %d no encontrado', $message->articleId));
        }

        $count = 0;
        foreach ($this->subscribers->findConfirmed() as $subscriber) {
            $email = new TemplatedEmail()
                ->from(new Address('no-reply@blog.test', 'Blog de Symfony'))
                ->to($subscriber->getEmail())
                ->subject('Nuevo artículo: '.$article->getTitle())
                ->htmlTemplate('emails/new_article.html.twig')
                ->context([
                    'article' => $article,
                    'unsubscribeToken' => $subscriber->getToken(),
                ]);

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

        $this->logger->info('Notificación de artículo enviada', ['article' => $article->getId(), 'emails' => $count]);
    }
}
Pieza Responsabilidad
Mensaje (SendNewArticleNotification) Describe qué hay que hacer, con datos serializables
Handler (#[AsMessageHandler]) Cómo se hace; el tipo del argumento de __invoke() indica qué mensaje maneja
Bus (MessageBusInterface) Entrega el mensaje
Transporte (async, failed…) Dónde espera el mensaje (cola)
Worker (messenger:consume) Proceso que lee la cola y ejecuta los handlers

8.4 Enviar el mensaje al bus

use App\Message\SendNewArticleNotification;
use Symfony\Component\Messenger\MessageBusInterface;

public function __construct(private MessageBusInterface $bus) {}

// ...
$this->bus->dispatch(new SendNewArticleNotification($article->getId()));

8.5 Transportes y routing: messenger.yaml

Sin configuración, los mensajes se procesan de forma síncrona (en el momento). Para hacerlos asíncronos, defines transportes y enrutas los mensajes hacia ellos:

# config/packages/messenger.yaml
framework:
    messenger:
        # a dónde van los mensajes que fallan definitivamente
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000        # 1 s antes del primer reintento
                    multiplier: 2      # 1 s, 2 s, 4 s...
                    max_delay: 60000
            failed: 'doctrine://default?queue_name=failed'
            # sync: 'sync://'

        routing:
            # los emails del Mailer también se envían en segundo plano
            Symfony\Component\Mailer\Messenger\SendEmailMessage: async
            App\Message\SendNewArticleNotification: async

when@test:
    framework:
        messenger:
            transports:
                # en tests, la cola vive en memoria
                async: 'in-memory://'

También puedes enrutar con un atributo en el propio mensaje:

use Symfony\Component\Messenger\Attribute\AsMessage;

#[AsMessage('async')]
final readonly class SendNewArticleNotification
{
    // ...
}
Transporte DSN Paquete adicional Comentario
Síncrono sync:// — Se ejecuta al momento (útil para depurar)
En memoria in-memory:// — Tests
Doctrine doctrine://default symfony/doctrine-messenger Usa tu base de datos; ideal para empezar
Redis redis://localhost:6379/messages symfony/redis-messenger Rápido, muy usado en producción
AMQP (RabbitMQ) amqp://guest:guest@localhost:5672/%2f/messages symfony/amqp-messenger Colas avanzadas
Amazon SQS sqs://... symfony/amazon-sqs-messenger En AWS

💡 Consejo: Con auto_setup=0 en el DSN de Doctrine, la tabla messenger_messages no se crea sola: genérala con una migración (make:migration la detecta) o ejecuta php bin/console messenger:setup-transports.

8.6 El worker: messenger:consume

Los mensajes asíncronos se quedan en la cola hasta que un worker los procesa:

# Procesa la cola async (Ctrl+C para parar)
php bin/console messenger:consume async -vv

# Varias colas, por orden de prioridad
php bin/console messenger:consume async_priority async

# Límites para que el proceso se reinicie periódicamente
php bin/console messenger:consume async --limit=50 --time-limit=3600 --memory-limit=128M

Salida aproximada con -vv:

 [OK] Consuming messages from transport "async".

 // The worker will automatically exit once it has received a stop signal via the messenger:stop-workers command.

 // Quit the worker with CONTROL-C.

12:04:31 INFO      [messenger] Received message App\Message\SendNewArticleNotification
12:04:31 INFO      [app] Notificación de artículo enviada ["article" => 42, "emails" => 3]
12:04:31 INFO      [messenger] App\Message\SendNewArticleNotification was handled successfully (acknowledging to transport).

⚠️ Importante: El worker es un proceso PHP de larga duración que carga tu código al arrancar. Si cambias un handler, reinicia el worker (en producción: php bin/console messenger:stop-workers tras cada despliegue). En servidores se mantiene vivo con Supervisor o systemd; lo verás en el módulo 15.

8.7 Reintentos y mensajes fallidos

Si un handler lanza una excepción, Messenger reintenta el mensaje según la retry_strategy. Tras agotar los reintentos, lo mueve al failure_transport:

# Ver los mensajes fallidos
php bin/console messenger:failed:show
php bin/console messenger:failed:show 15 -vv   # detalle y traza de uno

# Reintentarlos (por ejemplo, tras arreglar el SMTP)
php bin/console messenger:failed:retry
php bin/console messenger:failed:retry 15 --force

# Descartarlos
php bin/console messenger:failed:remove 15
Excepción en el handler Comportamiento
Cualquier excepción Se reintenta según retry_strategy
UnrecoverableMessageHandlingException No se reintenta; va directo a failed
RecoverableMessageHandlingException Se reintenta siempre (incluso por encima del máximo)

9. Ejemplo completo: avisar a los suscriptores al publicar

Juntamos todas las piezas. El flujo:

sequenceDiagram
    participant Admin as Administrador
    participant Ctrl as ArticleAdminController
    participant Pub as ArticlePublisher
    participant ED as EventDispatcher
    participant L as NotifySubscribersListener
    participant Bus as MessageBus
    participant Q as Cola async
    participant W as Worker
    participant H as Handler
    participant M as Mailer

    Admin->>Ctrl: POST /admin/articulos/42/publicar
    Ctrl->>Pub: publish($article)
    Pub->>Pub: publishedAt + flush
    Pub->>ED: ArticlePublishedEvent
    ED->>L: __invoke(event)
    L->>Bus: SendNewArticleNotification(42)
    Bus->>Q: guarda el mensaje
    Ctrl-->>Admin: 303 "Artículo publicado" (inmediato)
    W->>Q: lee el mensaje
    W->>H: __invoke(message)
    H->>M: send(TemplatedEmail) × N suscriptores

9.1 La entidad Subscriber

<?php
// src/Entity/Subscriber.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\SubscriberRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: SubscriberRepository::class)]
#[UniqueEntity(fields: ['email'], message: 'Este email ya está suscrito.')]
class Subscriber
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank]
    #[Assert\Email]
    private ?string $email = null;

    #[ORM\Column(length: 64, unique: true)]
    private string $token;

    #[ORM\Column]
    private bool $confirmed = false;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct()
    {
        $this->token = bin2hex(random_bytes(32)); // para confirmar y darse de baja
        $this->createdAt = new \DateTimeImmutable();
    }

    // ... getters y setters
}
// src/Repository/SubscriberRepository.php (fragmento)
/**
 * @return iterable<Subscriber>
 */
public function findConfirmed(): iterable
{
    // toIterable() no carga todos en memoria a la vez
    return $this->createQueryBuilder('s')
        ->andWhere('s.confirmed = true')
        ->getQuery()
        ->toIterable();
}

9.2 El listener que conecta evento y mensaje

<?php
// src/EventListener/NotifySubscribersListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Event\ArticlePublishedEvent;
use App\Message\SendNewArticleNotification;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsEventListener(event: ArticlePublishedEvent::class)]
final readonly class NotifySubscribersListener
{
    public function __construct(private MessageBusInterface $bus)
    {
    }

    public function __invoke(ArticlePublishedEvent $event): void
    {
        if (!$event->notifySubscribers) {
            return;
        }

        // Solo encola la tarea: la respuesta HTTP no espera a los emails
        $this->bus->dispatch(new SendNewArticleNotification($event->article->getId()));
    }
}

9.3 El controlador

Recuperamos la casilla no mapeada notifySubscribers del formulario del módulo 07:

#[Route('/admin/articulos/{id}/publicar', name: 'app_admin_article_publish', methods: ['POST'])]
#[IsGranted('ARTICLE_EDIT', 'article')]
public function publish(Article $article, Request $request, ArticlePublisher $publisher): Response
{
    $notify = $request->getPayload()->getBoolean('notify', true);

    try {
        $publisher->publish($article, notifySubscribers: $notify);
        $this->addFlash('success', $notify
            ? 'Artículo publicado. Los suscriptores recibirán un email en breve.'
            : 'Artículo publicado.');
    } catch (\LogicException $e) {
        $this->addFlash('warning', $e->getMessage());
    }

    return $this->redirectToRoute('app_admin_article_index', status: Response::HTTP_SEE_OTHER);
}

9.4 Probarlo

# 1. Mailpit en marcha (sección 7.2)
# 2. Crea algunos suscriptores confirmados (fixtures o a mano)
# 3. Publica un artículo desde el panel: la respuesta es inmediata
# 4. Comprueba que el mensaje está en la cola
php bin/console messenger:stats

# 5. Arranca el worker
php bin/console messenger:consume async -vv

# 6. Abre http://localhost:8025 y verás los emails

💡 Consejo: Fíjate en el doble nivel asíncrono: el handler llama a $mailer->send(), y como SendEmailMessage también está enrutado a async, cada email se convierte en otro mensaje de la cola. Así, si falla el envío a un suscriptor, solo se reintenta ese email, no los 500.

💡 Consejo: Mientras desarrollas, si no quieres tener un worker abierto, enruta temporalmente a sync (o cambia MESSENGER_TRANSPORT_DSN=sync:// en .env.local): los mensajes se procesarán en la propia petición.


10. Notifier (mención)

El componente Notifier generaliza la idea de “avisar a alguien” por distintos canales: email, SMS, chat (Slack, Telegram, Discord…), notificaciones push o en el navegador.

composer require symfony/notifier
# y el puente del canal, por ejemplo:
composer require symfony/telegram-notifier
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

public function __construct(private NotifierInterface $notifier) {}

public function notifyAdmin(Comment $comment): void
{
    $notification = new Notification('Nuevo comentario pendiente de revisar', ['email', 'chat/telegram'])
        ->content(sprintf('%s ha comentado en "%s"', $comment->getAuthorName(), $comment->getArticle()->getTitle()))
        ->importance(Notification::IMPORTANCE_MEDIUM);

    $this->notifier->send($notification, new Recipient('admin@blog.test'));
}

Los canales se configuran con DSN en config/packages/notifier.yaml (por ejemplo, TELEGRAM_DSN). Igual que con Mailer, los mensajes de chat y SMS se pueden enrutar a Messenger para enviarlos en segundo plano.


11. Errores comunes y buenas prácticas

Problema Causa Solución
El listener no se ejecuta Nombre de evento o tipo incorrecto debug:event-dispatcher <evento> para ver si está registrado
El listener se ejecuta dos veces por petición Responde también a subpeticiones Comprueba $event->isMainRequest()
Los mensajes no se procesan No hay worker en marcha messenger:consume async (o transporte sync:// en dev)
El worker no ve tus cambios de código Proceso de larga duración Reinícialo (messenger:stop-workers)
“Table messenger_messages doesn’t exist” auto_setup=0 sin migración make:migration o messenger:setup-transports
Entidad desactualizada en el handler Se pasó la entidad en el mensaje Pasa el id y recarga en el handler
Los enlaces de los emails apuntan a localhost No hay petición HTTP en el worker Configura framework.router.default_uri
Emails que llegan a clientes reales en desarrollo MAILER_DSN de producción en local Usa Mailpit y envelope.recipients en when@dev

💡 Consejo: Buenas prácticas:

  • Usa eventos para desacoplar efectos secundarios de la acción principal, y Messenger para lo que sea lento o pueda fallar (emails, llamadas a APIs, generación de PDFs, redimensionar imágenes).
  • Mensajes pequeños, inmutables (readonly) y con identificadores.
  • Handlers idempotentes cuando sea posible: si un mensaje se procesa dos veces (puede ocurrir), el resultado no debería duplicarse.
  • Lanza eventos de negocio después del flush(): si la transacción falla, nadie debe enterarse de algo que no ocurrió.

12. Ejercicios

Ejercicio 1: Modo mantenimiento

Implementa MaintenanceListener controlado por la variable BLOG_MAINTENANCE. Crea la plantilla maintenance.html.twig con Bootstrap. Comprueba que, con la variable a true, la portada devuelve 503 y /admin sigue funcionando. Verifica su prioridad con debug:event-dispatcher kernel.request.

Ejercicio 2: Cabeceras de seguridad

Crea un listener de kernel.response que añada X-Content-Type-Options: nosniff y Referrer-Policy solo en la petición principal. Compruébalo en la pestaña Red de las herramientas del navegador. Reescríbelo después como subscriber y compara ambos estilos.

Ejercicio 3: Evento de comentario

Crea CommentPostedEvent y lánzalo desde el controlador del módulo 07 tras guardar un comentario. Escribe un listener que registre en el log el autor y el título del artículo comentado.

Ejercicio 4: Slug y fechas automáticos

Añade a Article los callbacks #[ORM\PrePersist] (rellena createdAt) y #[ORM\PreUpdate] (rellena updatedAt), y el entity listener ArticleSlugListener. Elimina de los controladores el código que generaba slugs y fechas y comprueba que todo sigue funcionando.

Ejercicio 5: Formulario de contacto por email

Retoma el formulario de contacto del módulo 07 y envía su contenido al administrador con un TemplatedEmail (con plantilla HTML y texto). Arranca Mailpit con Docker y verifica el correo en http://localhost:8025.

Ejercicio 6: Suscripción al blog

Crea la entidad Subscriber, un formulario de suscripción en el pie de página y un email de confirmación con un enlace /suscripcion/confirmar/{token} que marque confirmed = true. Añade también la ruta de baja /suscripcion/baja/{token}.

Ejercicio 7: Notificación asíncrona

Implementa el flujo completo de la sección 9: ArticlePublishedEvent → NotifySubscribersListener → SendNewArticleNotification → handler. Configura el transporte async con Doctrine. Publica un artículo sin el worker en marcha y comprueba con messenger:stats que hay mensajes pendientes; después arranca messenger:consume async -vv.

Ejercicio 8: Fallos y reintentos

Pon en .env.local un MAILER_DSN incorrecto (por ejemplo, un puerto en el que no escucha nada) y publica un artículo. Observa en la salida del worker los reintentos y, al final, revisa el mensaje con messenger:failed:show. Corrige el DSN, reinicia el worker y recupera los mensajes con messenger:failed:retry.


Resumen

Concepto Sintaxis / herramienta
Listener #[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
Subscriber implements EventSubscriberInterface + getSubscribedEvents()
Eventos del kernel kernel.request, controller, response, exception, terminate…
Petición principal $event->isMainRequest()
Ver listeners php bin/console debug:event-dispatcher
Evento propio class X extends Event + $dispatcher->dispatch(new X(...))
Callbacks Doctrine #[ORM\HasLifecycleCallbacks] + #[ORM\PrePersist]
Listener de entidad #[AsEntityListener(event: Events::prePersist, entity: Article::class)]
Listener de Doctrine #[AsDoctrineListener(event: Events::postRemove)]
Mailer composer require symfony/mailer, MAILER_DSN, MailerInterface::send()
Email con plantilla new TemplatedEmail()->htmlTemplate()->context([...])
Mailpit docker run -p 1025:1025 -p 8025:8025 axllent/mailpit
Mensaje Clase readonly con datos serializables (ids)
Handler #[AsMessageHandler] + __invoke(Mensaje $m)
Enviar al bus MessageBusInterface::dispatch(new Mensaje(...))
Routing framework.messenger.routing o #[AsMessage('async')]
Worker php bin/console messenger:consume async -vv
Fallos failure_transport, messenger:failed:show, messenger:failed:retry
Notifier NotifierInterface::send(new Notification(...), $recipient)