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 conEventSubscriberInterface - 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-dispatchero 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:listenerte genera el esqueleto: te pregunta el evento (puedes escribirkernel.responseo 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 enpreUpdatesolo 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 comoArticlePublishedEvent: 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 enMAILER_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 -dy el servidor consymfony serve, la Symfony CLI detecta el serviciomailery defineMAILER_DSNautomáticamente. Consymfony open:local:webmailabres 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 nopath()(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 enframework.router.default_uri(por ejemplo,https://blog.test).
💡 Consejo: La variable especial
email.subject,email.to…) y permite incrustar imágenes conemail.image('@images/logo.png'). El filtrou.truncaterequieretwig/string-extra; para estilos complejos existe el filtroinline_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=0en el DSN de Doctrine, la tablamessenger_messagesno se crea sola: genérala con una migración (make:migrationla detecta) o ejecutaphp 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-workerstras 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 comoSendEmailMessagetambién está enrutado aasync, 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 cambiaMESSENGER_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) |