Cuando una aplicación PHP empieza a crecer, uno de los primeros problemas de rendimiento suele aparecer en el peor lugar posible: la experiencia del usuario.
Un registro que tarda demasiado.
Una compra que demora varios segundos.
Una API que parece «congelarse» antes de responder.
En muchos casos, el problema no es PHP en sí. El problema es ejecutar demasiadas tareas dentro de una misma petición HTTP. Y eso tiene solución.
El problema: tareas bloqueantes en la request
Imaginemos un flujo típico de registro de usuario:
- El usuario envía el formulario
- La aplicación crea el usuario en base de datos
- Se envía un email de bienvenida
- Se genera un PDF de confirmación
- Se notifica a un webhook externo
- Finalmente se devuelve un
HTTP 200
El problema es que todas esas operaciones ocurren de forma secuencial, dentro de la misma request:
Usuario espera...
├── Guardar usuario
├── Enviar email
├── Generar PDF
├── Llamar webhook
└── Respuesta HTTP
Aunque cada tarea tarde «solo» algunos cientos de milisegundos, el tiempo total se acumula rápidamente. Y hay algo todavía peor: dependes de sistemas externos. SMTP lentos, APIs inestables, timeouts de red… Todo eso impacta directamente sobre el tiempo de respuesta que experimenta el usuario.
La solución: desacoplar las tareas pesadas del ciclo HTTP
La solución moderna consiste en separar las tareas pesadas de la request. En lugar de ejecutar todo inmediatamente, la aplicación:
- Guarda la información mínima necesaria
- Publica un mensaje en una cola
- Devuelve la respuesta HTTP lo antes posible
- Un proceso independiente ejecuta el trabajo en segundo plano
HTTP Request
↓
Controller → dispatch()
↓
Cola / Transport
↓
Respuesta HTTP inmediata
(En paralelo)
Worker consume la cola
↓
Handler ejecuta la lógica
Este patrón tiene varios nombres, background jobs, message queues, procesamiento asíncrono, pero todos describen la misma idea: el usuario no tiene por qué esperar mientras el servidor genera un PDF o llama a una API externa.
En el ecosistema Symfony, el componente estándar para implementar este patrón es Symfony Messenger.
Las piezas del sistema
Messenger se construye sobre cuatro conceptos fundamentales que conviene entender antes de escribir una sola línea de configuración.
Message
El mensaje es un objeto simple que contiene exclusivamente los datos necesarios para ejecutar una tarea. Nada más.
// src/Message/SendWelcomeEmail.php
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
) {}
}
Una regla importante: los mensajes deben contener IDs y datos mínimos, no entidades Doctrine completas ni servicios. ¿Por qué? Porque los mensajes se serializan para guardarse en la cola. Pasar una entidad completa implica serializar todo el grafo de objetos, lo que genera mensajes enormes, frágiles y difíciles de versionar. Un simple $userId es suficiente: el handler recuperará los datos frescos cuando procese el mensaje.
Message Bus
El bus recibe el mensaje y decide qué hacer con él según la configuración.
$bus->dispatch(new SendWelcomeEmail($userId));
El controlador que ejecuta esta línea no sabe dónde se procesará el mensaje, cuándo ni quién lo hará. Eso es precisamente el desacoplamiento que buscamos.
Message Handler
El handler contiene la lógica real de negocio. Symfony lo detecta automáticamente y lo asocia al mensaje correspondiente gracias al atributo #[AsMessageHandler] y al tipo del argumento de __invoke.
// src/MessageHandler/SendWelcomeEmailHandler.php
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __invoke(SendWelcomeEmail $message): void
{
// Aquí va la lógica: recuperar el usuario y enviar el email
}
}
Transport
El transporte define dónde se almacenan los mensajes mientras esperan ser procesados. Es la pieza que determina si la ejecución es síncrona o asíncrona, y qué tecnología de cola se usa por debajo.
Instalación
composer require symfony/messenger
Configurando el transporte
Ejecución síncrona vs. asíncrona
Por defecto, sin configurar ningún transporte, Messenger ejecuta el handler inmediatamente dentro de la misma request. Esto es útil para desarrollo y debugging, pero no elimina el bloqueo.
En modo asíncrono, el mensaje se guarda primero en el transporte y el worker lo procesa después, de forma completamente independiente a la request original.
Doctrine como punto de partida
Para empezar, Doctrine suele ser la mejor opción: no requiere RabbitMQ, Redis ni ningún servicio externo. Solo necesitas la base de datos que ya tienes.
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Message\SendWelcomeEmail': async
MESSENGER_TRANSPORT_DSN=doctrine://default
Luego crea la tabla donde se almacenarán los mensajes pendientes:
php bin/console messenger:setup-transports
Esto genera la tabla messenger_messages en tu base de datos. Cada mensaje pendiente queda allí hasta que el worker lo consuma.
Despachando desde un controlador
namespace App\Controller;
use App\Message\SendWelcomeEmail;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
final class RegisterController
{
public function __invoke(MessageBusInterface $bus): Response
{
$userId = 42;
$bus->dispatch(new SendWelcomeEmail($userId));
return new Response('Usuario registrado');
}
}
Desde el punto de vista del usuario, la respuesta es inmediata. El email se enviará después, en segundo plano.
Ejecutando el worker
El worker es el proceso que consume mensajes de la cola y ejecuta los handlers:
php bin/console messenger:consume async
En producción, este comando debe ejecutarse continuamente. Para garantizar que se reinicia automáticamente ante fallos, necesitas un supervisor de procesos como Supervisor, systemd, o la orquestación que uses (Docker, Kubernetes).
Resiliencia y fallos
Reintentos automáticos
Una de las mayores ventajas de Messenger es su sistema de reintentos. Si un handler lanza una excepción, porque el servidor SMTP no responde, porque una API externa está caída, porque hay un problema de red, Symfony puede reintentar el mensaje automáticamente, con un intervalo configurable entre intentos.
Esto convierte errores temporales en algo manejable, en lugar de algo que simplemente se pierde.
Failed Transport: no perder mensajes importantes
Pero ¿qué ocurre si un mensaje falla de forma permanente, incluso después de varios reintentos? Symfony permite moverlo a una cola de errores separada:
framework:
messenger:
failure_transport: failed
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
failed: 'doctrine://default?queue_name=failed'
Desde ahí puedes inspeccionarlos y reintentarlos manualmente:
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
Esto evita perder trabajos importantes silenciosamente. Un sistema async que no gestiona los fallos no está listo para producción.
Idempotencia: el detalle que marca la diferencia
Cuando trabajas con reintentos, debes asumir que un mensaje puede ejecutarse más de una vez. Un email podría enviarse duplicado. Una API podría recibir dos llamadas. Un webhook podría procesarse dos veces.
Por eso los handlers deberían ser idempotentes siempre que sea posible: ejecutar el mismo mensaje dos veces no debería romper el sistema ni producir efectos no deseados.
Este detalle separa los sistemas async «de demo» de los sistemas realmente preparados para producción.
Qué tareas son buenas candidatas
Messenger resulta ideal para:
- Envío de emails y notificaciones push
- Generación de PDFs o procesamiento de imágenes
- Llamadas a webhooks y APIs externas
- Exportaciones masivas de datos
- Sincronización con servicios de terceros
En general, cualquier tarea que dependa de red, tarde demasiado o no sea crítica para responder al usuario de inmediato es una buena candidata.
Lo que no conviene mover a una cola: validaciones críticas previas a la persistencia, lógica que debe ejecutarse de forma inmediata, o tareas tan simples que el overhead de la cola no tiene ningún sentido. El objetivo no es «hacer todo async». El objetivo es desacoplar correctamente.
Observabilidad: lo que nadie menciona hasta que falla
Cuando introduces workers, introduces nuevos puntos de fallo que no existían antes. Un escenario común: el worker se cae en producción a las 3 de la mañana, los mensajes empiezan a acumularse en la cola, y nadie se entera hasta que un usuario escribe para preguntar por qué no recibió su email de confirmación hace seis horas.
Conviene monitorear activamente:
- Workers detenidos o no respondiendo
- Colas creciendo indefinidamente
- Mensajes fallidos acumulándose
- Tiempos de procesamiento fuera de lo normal
- Retries excesivos que indican un problema sistémico
Una cola silenciosamente bloqueada puede convertirse rápidamente en un problema operativo serio.
Apliquemos asincronía
Symfony Messenger ofrece una forma elegante y robusta de introducir procesamiento asíncrono en aplicaciones PHP modernas. Su mayor ventaja no es solo mejorar el rendimiento: es mejorar la arquitectura. Separar responsabilidades, reducir acoplamiento, eliminar bloqueos innecesarios en las requests HTTP.
Lo mejor es que puedes empezar de forma extremadamente simple con Doctrine, sin incorporar infraestructura compleja desde el primer día. Cuando el sistema crezca y lo necesites, migrar a Redis, RabbitMQ o Amazon SQS no requerirá tocar la lógica de negocio de tus handlers. Ese desacoplamiento es precisamente lo que hace de Messenger una herramienta tan potente dentro del ecosistema Symfony.
Tema Relacionado: Backend PHP & Symfony