planeta jupiter planeta tierra

Asincronía en Symfony en 7 minutos

· 7 min de lectura · Symfony

Ilustración de un ovni con el logotipo de Symfony
nave extraterrestre
Ilustración de un ovni con el logotipo de Symfony

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 Request viaja 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.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.