Tu controlador tarda tres segundos en responder porque está generando un PDF y mandando un correo. El usuario espera mirando el spinner. No hace falta: eso se saca de la petición y se procesa en segundo plano. En Symfony se hace con Messenger, y se monta en siete minutos.
El problema
Un controlador típico que hace demasiado:
public function register(Request $request): Response
{
$user = $this->createUser($request);
$this->mailer->send($welcomeEmail); // ~800 ms
$this->pdfGenerator->invoice($user); // ~1.5 s
$this->crm->sync($user); // ~700 ms si el CRM va fino
return $this->redirectToRoute('dashboard');
}
Tres segundos de espera para el usuario, y encima el registro falla entero si el CRM está caído: el usuario ve un error 500 cuando en realidad su cuenta ya existe. Ninguna de esas tres cosas necesita ocurrir antes de responder.
Cómo funciona Messenger
Son cuatro piezas y conviene tenerlas claras antes de escribir código, porque casi todos los problemas vienen de confundirlas:
Controlador --dispatch--> Bus --> Transporte (cola) --> Worker --> Handler
petición HTTP base de datos, Redis proceso aparte,
(responde ya) o RabbitMQ comando de consola
- Mensaje: un objeto plano con los datos del trabajo.
- Handler: el código que hace el trabajo.
- Transporte: dónde se aparca el mensaje hasta que alguien lo coge.
- Worker: el proceso que saca mensajes de la cola y llama al handler.
Sin transporte, el bus llama al handler en el acto: el código queda igual de limpio pero sigue siendo síncrono.
1. Instalar Messenger
composer require symfony/messenger
2. El mensaje
Un mensaje es un DTO plano: sin lógica, sin dependencias, solo los datos mínimos para hacer el trabajo después. Importante: guarda identificadores, no entidades de Doctrine, porque el mensaje se serializa y se procesa en otro proceso.
namespace App\Message;
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
) {
}
}
Cuanto más pequeño, mejor: un mensaje es un contrato que viaja en el tiempo. Si despliegas mientras hay mensajes en la cola, el worker nuevo tendrá que entender los mensajes viejos, así que evita cambiar o quitar propiedades a la ligera.
3. El handler
Aquí sí va la lógica y las dependencias. El atributo #[AsMessageHandler] es
todo lo que necesita Symfony para conectarlo.
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(
private UserRepository $users,
private MailerInterface $mailer,
) {
}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->users->find($message->userId);
if (null === $user) {
return; // el usuario se borró entre el envío y el consumo
}
$this->mailer->send(/* ... */);
}
}
El tipo del parámetro de __invoke() es lo que decide qué handler recibe qué
mensaje. No hace falta configurar nada más.
4. Despachar desde el controlador
use Symfony\Component\Messenger\MessageBusInterface;
public function register(Request $request, MessageBusInterface $bus): Response
{
$user = $this->createUser($request); // incluye el flush
$bus->dispatch(new SendWelcomeEmail($user->getId()));
return $this->redirectToRoute('dashboard');
}
Fíjate en el orden: primero se guarda el usuario y después se despacha. Al revés, un worker rápido puede coger el mensaje antes de que la transacción haya hecho commit y buscar en base de datos un id que todavía no existe. Es la carrera más habitual con Messenger y no da la cara hasta producción.
El controlador ya responde en milisegundos. Pero ojo: todavía no es asíncrono. Sin configurar transporte, Messenger ejecuta el handler en el acto, dentro de la misma petición. Falta el paso siguiente, que es justo donde mucha gente da por terminado el trabajo y se queda igual que estaba.
5. El transporte: aquí es donde pasa la magia
El transporte es la cola donde se aparcan los mensajes. Para empezar,
doctrine guarda la cola en una tabla de tu base de datos y no requiere
infraestructura nueva:
# .env
MESSENGER_TRANSPORT_DSN=doctrine://default
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
failure_transport: failed
routing:
App\Message\SendWelcomeEmail: async
Esa línea de routing es la que convierte el mensaje en asíncrono. Todo lo que
no aparezca ahí se sigue ejecutando de forma síncrona. Se aceptan comodines por espacio de
nombres, muy cómodo cuando ya tienes varios mensajes:
routing:
'App\Message\*': async
Con doctrine, la tabla messenger_messages la crea la propia
librería (o una migración, si prefieres controlarlo). Aguanta bien miles de mensajes al día;
cuando eso se quede corto, cambias el DSN por amqp:// o redis:// y
no tocas ni una línea de PHP.
6. Consumir la cola
php bin/console messenger:consume async -vv
Con -vv ves en pantalla cada mensaje recibido y cada handler ejecutado: es la
forma más rápida de comprobar que todo está bien conectado. En producción ese comando lo
mantiene vivo Supervisor o un servicio systemd, y conviene poner siempre dos opciones:
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=128M
Un proceso PHP de larga duración acumula memoria y se queda con la conexión a base de datos envejecida. Reiniciarlo cada hora sale gratis y evita fugas. Con Supervisor, el proceso vuelve a levantarse solo:
# /etc/supervisor/conf.d/messenger.conf
[program:messenger-async]
command=php /var/www/app/bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
numprocs es tu paralelismo: dos workers consumen dos mensajes a la vez. Súbelo
si la cola se acumula, pero recuerda que cada uno abre su propia conexión a base de datos.
Para ver cómo va la cola:
php bin/console messenger:stats
7. Reintentos y mensajes fallidos
Si el handler lanza una excepción, Messenger reintenta con espera creciente. Con la
configuración de antes (delay: 1000, multiplier: 2) los reintentos
van a 1 s, 2 s y 4 s. Agotados los tres, el mensaje se mueve al transporte
failed en lugar de perderse:
php bin/console messenger:failed:show # qué hay en la cola de fallos
php bin/console messenger:failed:show 42 -vv # el error completo de un mensaje
php bin/console messenger:failed:retry # reencolar, uno a uno y preguntando
Si un error no tiene arreglo reintentando (datos inválidos, un recurso que ya no existe),
lanza UnrecoverableMessageHandlingException: el mensaje va directo a
failed sin gastar los tres intentos.
8. Retrasar y priorizar
Un stamp es metadato que acompaña al mensaje. El más útil retrasa la entrega, por ejemplo para mandar un recordatorio una hora después:
use Symfony\Component\Messenger\Stamp\DelayStamp;
$bus->dispatch(new SendWelcomeEmail($user->getId()), [
new DelayStamp(3600000), // en milisegundos
]);
Y si un trabajo lento no puede bloquear a los rápidos, sepáralos en dos transportes con sus propios workers:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
async_priority_low: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=low'
routing:
App\Message\SendWelcomeEmail: async
App\Message\GenerateMonthlyReport: async_priority_low
9. Probarlo en los tests
En el entorno de test usa el transporte in-memory: los mensajes no se ejecutan,
se quedan guardados y puedes comprobar qué se ha despachado.
# .env.test
MESSENGER_TRANSPORT_DSN=in-memory://
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
public function testRegistroEncolaElCorreoDeBienvenida(): void
{
$client = static::createClient();
$client->request('POST', '/register', ['email' => 'ana@example.com']);
/** @var InMemoryTransport $transport */
$transport = self::getContainer()->get('messenger.transport.async');
$this->assertCount(1, $transport->getSent());
}
El handler se prueba aparte, como cualquier otra clase: instánciala y llámala. Esa es otra ventaja de tener el trabajo fuera del controlador.
Lo que se rompe si no lo sabes
- El worker no ve tu código nuevo. Tras cada despliegue hay que lanzar
messenger:stop-workers; si no, sigue corriendo la versión antigua hasta que caduque su--time-limit. - Nunca metas entidades en el mensaje. Se serializan enteras y llegan al handler desconectadas del EntityManager. Manda el id y recárgala.
- Despacha después del flush. Si el mensaje sale antes del commit, el worker puede buscar un registro que todavía no está en base de datos.
- Los mensajes fallidos no desaparecen. Tras agotar los reintentos van al
transporte
failed. Si nadie lo mira, se acumulan en silencio: revísalo o monitorízalo. - El handler debe ser idempotente. Un mensaje puede procesarse dos veces si el worker muere justo después de trabajar y antes de confirmar. Comprueba antes de actuar: si el correo ya se envió, no lo mandes otra vez.
- Nada del
Requestviaja al worker. No hay sesión, ni usuario autenticado, ni locale: lo que necesites, mételo en el mensaje.
Y ya está
Mensaje, handler, transporte y consumidor. Con eso el registro responde al instante y el correo sale cuando salga. Cuando la tabla de Doctrine se quede corta, se cambia el DSN por uno de RabbitMQ o Redis y no se toca ni una línea de PHP: esa es la gracia de todo esto.