Las colas son de esas cosas que en local nunca dan guerra. Arrancamos el proyecto con composer run dev, despachamos un job y se ejecuta. El problema llega cuando subimos la aplicación a un servidor y empiezan a pasar cosas raras, como emails que llegan dos veces o workers que siguen ejecutando código de hace una semana.
En este artículo vamos a repasar lo que hay que configurar para tener las colas funcionando en producción con Laravel 13 (casi todo sirve también para versiones anteriores). Para prepararlo montamos un proyecto de pruebas con DDEV y Laravel 13.34 y forzamos a propósito varios de estos fallos. Alguno no se comportó como esperábamos, así que lo iremos contando por el camino.
Elegir el driver
Desde Laravel 11 el driver por defecto es database, que guarda los jobs en la tabla jobs. Para muchas aplicaciones es suficiente y no hace falta instalar nada más.
- database: volumen bajo o medio. No añade infraestructura y ya tienes backups de la base de datos.
- redis: mucho volumen, latencia baja o si quieres usar Horizon.
- sqs: si ya estás en AWS y prefieres un servicio gestionado.
Nosotros solemos empezar con database y pasar a Redis solo cuando hace falta, por ejemplo cuando se mueven miles de jobs por minuto o cuando la tabla jobs empieza a notarse en la base de datos. El cambio más adelante es de configuración, el código de los jobs no se toca.
QUEUE_CONNECTION=database
queue:work y por qué hay que reiniciarlo
En producción los jobs se procesan con:
php artisan queue:workqueue:work arranca la aplicación una vez, la deja cargada en memoria y va procesando jobs uno detrás de otro. Por eso es rápido. Pero también significa que, si desplegamos una versión nueva, el worker seguirá ejecutando la antigua hasta que lo reiniciemos. Y cualquier propiedad estática o singleton que modifique un job seguirá modificado en el siguiente.
Existe también queue:listen, que recarga la aplicación en cada job y no necesita reinicios, aunque es bastante más lento. Es el que usa composer run dev en local. Para producción nos quedamos con queue:work.
Mantener los workers vivos con Supervisor
Si lanzamos queue:work a mano en una terminal, morirá en cuanto cerremos la sesión. Además, un worker puede pararse por muchos motivos (un job que supera el timeout, falta de memoria, un queue:restart...), así que necesitamos algo que lo vuelva a levantar. En Linux lo habitual es Supervisor:
sudo apt-get install supervisorCreamos el fichero /etc/supervisor/conf.d/laravel-worker.conf:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600Con numprocs=4 tendremos cuatro workers en paralelo; el número dependerá de la carga y de la CPU del servidor. El usuario (user=www-data) tiene que ser el mismo que ejecuta PHP en la web, porque si no acabaremos con problemas de permisos en storage.
El --max-time=3600 hace que cada worker se cierre al cabo de una hora y Supervisor lo arranque de nuevo, lo que evita que la memoria vaya creciendo poco a poco (con --max-jobs=1000 conseguimos lo mismo contando jobs). Y stopwaitsecs es lo que Supervisor espera a que un worker termine su job antes de matarlo. Si nuestro job más largo tarda más que eso, Supervisor lo cortará a medias.
Para cargar la configuración y arrancar los workers:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"En el proyecto de pruebas usamos esta misma configuración (cambiando las rutas) con el Supervisor que trae el contenedor de DDEV, y arrancó los cuatro workers sin problema.
Si trabajamos con Docker, no hace falta Supervisor dentro del contenedor. Es más sencillo tener un contenedor dedicado al worker, con la misma imagen que la web, el comando php artisan queue:work y la política restart: unless-stopped. Para tener más workers, se escala ese servicio.
Desplegar sin romper nada
Como los workers no ven el código nuevo, en cada despliegue hay que reiniciarlos:
php artisan queue:restartEl comando no mata los workers en seco. Les avisa para que terminen el job que tienen entre manos y salgan, y Supervisor los vuelve a arrancar ya con el código nuevo. Por eso conviene dejarlo al final del script de despliegue, después de composer install, las migraciones y php artisan optimize.
La señal de reinicio se guarda en la caché, y esto da más problemas de los que parece. Con CACHE_STORE=array la señal no llega nunca. Lo probamos y el comando responde "Broadcasting queue restart signal" como si todo hubiera ido bien, pero los workers siguen a lo suyo. Con varios servidores y caché file pasa algo parecido: cada servidor tiene su propia caché y solo se reinician los workers del servidor donde ejecutamos el comando. Si hay más de un servidor, la caché tiene que ser compartida (Redis o database).
Cuando el reinicio funciona, en el log de cada worker aparece esto:
Worker STOPPED Received restart signalHay otro detalle del despliegue que pilla a mucha gente. Los jobs que ya estaban en la cola se crearon con el código anterior: Laravel guarda el objeto del job serializado y, al procesarlo, lo reconstruye sin pasar por el constructor. Si el despliegue cambia la clase de forma incompatible, esos jobs pendientes fallarán. Lo más habitual es renombrar o mover la clase del job, o añadirle una propiedad nueva.
Con las propiedades nuevas nos llevamos una sorpresa al probarlo. Nuestra primera idea fue darle un valor por defecto en el constructor:
public function __construct(
public Invoice $invoice,
public string $channel = 'email', // Nueva propiedad
) {}No funciona. Como el constructor no se ejecuta al reconstruir el job, $channel se queda sin inicializar y el job pendiente falla con:
Typed property App\Jobs\SendInvoice::$channel must not be accessed before initializationLo que sí funciona es declarar la propiedad fuera del constructor, con su valor por defecto:
public string $channel = 'email';
public function __construct(public Invoice $invoice) {}Si usamos Horizon, el equivalente a queue:restart es php artisan horizon:terminate.
timeout, retry_after, tries y backoff
Esta es la parte que más nos ha costado entender de las colas. Son cuatro valores distintos que se pisan entre sí, y cuando no cuadran aparecen jobs ejecutados dos veces o jobs que desaparecen.
- timeout: segundos que puede durar un job antes de que el worker lo mate. Se configura con --timeout o #[Timeout]. Por defecto, 60.
- retry_after: segundos que la cola espera antes de dar un job por perdido y entregarlo a otro worker. Se configura en config/queue.php. Por defecto, 90.
- tries: número máximo de intentos antes de marcarlo como fallido. Se configura con --tries o #[Tries]. Por defecto, 1.
- backoff: segundos de espera entre un intento fallido y el siguiente. Se configura con --backoff o #[Backoff]. Por defecto, 0.
timeout tiene que ser menor que retry_after
Con los valores por defecto (60 y 90) no hay problema. Se rompe cuando tenemos un job lento y subimos el timeout sin acordarnos de retry_after. Por ejemplo, lanzamos los workers con --timeout=300 porque un informe tarda unos dos minutos, pero retry_after sigue en 90. A los 90 segundos la cola da el job por perdido y se lo entrega a otro worker, mientras el primero sigue trabajando en él.
Lo reprodujimos con dos workers, un job de 20 segundos y retry_after a 10, y el resultado dependió de tries. Con --tries=3, el segundo worker cogió el job a los 10 segundos y lo ejecutó entero otra vez, en paralelo con el primero. Con --tries=1, que es el valor por defecto, el segundo worker lo cogió, vio que no le quedaban intentos y lo marcó como fallido con has been attempted too many times, mientras el primero lo terminaba sin problemas.
Este segundo caso nos parece especialmente interesante, porque explica un error que aparece mucho en Stack Overflow: un job en failed_jobs con "attempted too many times or run too long" que en realidad sí se ejecutó. Si os pasa, antes de revisar el código del job merece la pena revisar estos tiempos.
Así que, si tocamos uno de los dos valores, hay que tocar el otro. retry_after se puede cambiar desde el .env con DB_QUEUE_RETRY_AFTER (o REDIS_QUEUE_RETRY_AFTER con Redis), y stopwaitsecs de Supervisor tiene que quedar por encima de ambos.
Jobs lentos en una conexión aparte
Cuando solo unos pocos jobs son lentos (informes, importaciones, vídeo...), no tiene sentido subir los tiempos de toda la cola. Es mejor crear una conexión dedicada en config/queue.php:
'database-long' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => 'long',
'retry_after' => 3700,
'after_commit' => false,
],En Laravel 13 podemos mandar esos jobs a esa conexión desde un service provider, sin tocar cada dispatch:
use App\Jobs\GenerateAnnualReport;
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::route(GenerateAnnualReport::class, connection: 'database-long', queue: 'long');
}Y le damos su propio programa en Supervisor:
command=php /var/www/app/artisan queue:work database-long --queue=long --timeout=3600 --tries=1Configurarlo en el propio job
En Laravel 13 todos estos valores se pueden declarar con atributos en la clase del job, y tienen prioridad sobre las opciones de queue:work:
<?php
namespace App\Jobs;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Http;
#[Tries(5)]
#[MaxExceptions(3)]
#[Backoff([10, 60, 300])]
#[Timeout(30)]
class SendInvoiceToErp implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function handle(): void
{
Http::timeout(10)
->post(config('services.erp.url').'/invoices', $this->invoice->toErpPayload())
->throw();
}
}Este job se intentará hasta cinco veces, pero fallará en cuanto acumule tres excepciones. Entre intentos espera 10 segundos la primera vez, 60 la segunda y 300 a partir de ahí, y si algún intento pasa de 30 segundos el worker lo corta. En las pruebas, con un job que siempre lanzaba una excepción, vimos exactamente esas esperas y el fallo definitivo al tercer intento.
Hemos puesto también un Http::timeout(10) en el cliente porque el timeout del job no siempre es capaz de cortar una petición HTTP o una conexión de red que se ha quedado colgada. Además, el timeout de los jobs necesita la extensión pcntl de PHP, que suele estar en Linux pero no en Windows.
Si preferimos que un job que supera el timeout falle directamente en lugar de reintentarse, basta con añadir #[FailOnTimeout].
Cuando los jobs fallan
Cuando un job agota sus intentos, Laravel lo guarda en la tabla failed_jobs junto con la excepción. Estos son los comandos que más vamos a usar:
# Ver los jobs fallidos
php artisan queue:failed
# Reintentar uno concreto, todos los de una cola o todos
php artisan queue:retry ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece
php artisan queue:retry --queue=emails
php artisan queue:retry all
# Borrar uno concreto o todos
php artisan queue:forget ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece
php artisan queue:flushLimpiar después de un fallo
Si el job puede dejar algo a medias, podemos definir un método failed(). Se ejecuta una sola vez, cuando el job se da por fallido del todo:
use Throwable;
public function failed(?Throwable $exception): void
{
$this->invoice->update(['erp_status' => 'error']);
}No reintentar lo que no tiene arreglo
Hay errores que no se arreglan reintentando. Si una factura tiene un dato incorrecto, el job fallará igual las cinco veces. En bootstrap/app.php podemos indicar qué excepciones deben hacer fallar el job sin más intentos:
use App\Exceptions\InvalidInvoiceException;
use Illuminate\Foundation\Configuration\Exceptions;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontRetry([
InvalidInvoiceException::class,
]);
})Aquí perdimos un buen rato. En el proyecto de pruebas, dontRetry no hacía nada y el job se reintentaba igual. El culpable resultó ser Collision, el paquete que formatea los errores en la consola y que viene en require-dev en cualquier proyecto nuevo. En consola sustituye el gestor de excepciones de Laravel por el suyo, y su versión 8.9.5 no implementa el método que consulta el worker. En producción, al instalar con composer install --no-dev, Collision no está y dontRetry funciona bien. Lo comprobamos desactivándolo en local. Así que, si en vuestro entorno parece que no hace nada, probablemente sea esto.
Otro caso habitual es que el modelo que recibe el job se haya borrado mientras esperaba en la cola. Con el atributo #[DeleteWhenMissingModels], Laravel descarta el job sin lanzar un ModelNotFoundException.
Enterarse de que algo ha fallado
La tabla failed_jobs no avisa a nadie, y si nadie la mira los fallos pueden pasar semanas sin que lo sepamos. Lo mínimo es registrar un listener en AppServiceProvider que mande un aviso:
use App\Notifications\JobFailedNotification;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::failing(function (JobFailed $event) {
Notification::route('mail', 'dev@example.com')
->notify(new JobFailedNotification($event->job->resolveName(), $event->exception));
});
}Este listener solo salta cuando el job falla del todo, no en cada intento. Si usáis Sentry, Flare o Nightwatch, las excepciones de los jobs ya os llegan igual que las de las peticiones web y puede que no os haga falta.
Prioridades y colas separadas
Por defecto todo va a la cola default. Si justo antes del email de recuperar la contraseña entra un envío de 5.000 emails de newsletter, el usuario tendrá que esperar a que salgan todos.
Para evitarlo, separamos los jobs en colas y le decimos al worker en qué orden atenderlas:
SendPasswordResetEmail::dispatch($user)->onQueue('high');
SendNewsletter::dispatch($subscriber)->onQueue('low');php artisan queue:work --queue=high,default,lowEl worker no tocará default mientras queden jobs en high, ni low mientras queden en default. Eso sí, si high no se vacía nunca, low no avanzará. Cuando una cola necesite tener siempre capacidad, es mejor darle su propio programa de Supervisor con sus propios workers.
Jobs dentro de transacciones
Este caso cuesta de detectar porque en local casi nunca falla:
DB::transaction(function () use ($data) {
$order = Order::create($data);
SendOrderConfirmation::dispatch($order);
// ... más operaciones
});Si un worker recoge el job antes de que termine la transacción, para él el pedido todavía no existe y el job falla con un ModelNotFoundException. Como Laravel no puede reconstruir el job sin el modelo, tampoco llega a ejecutarse su método failed(). Con poca carga es raro que pase, pero en producción acaba pasando.
Al probarlo descubrimos que el comportamiento depende del driver, algo que no habíamos visto comentado. Con database, si la cola usa la misma conexión que la aplicación, el propio job se inserta en la tabla jobs dentro de la transacción, así que ningún worker lo ve hasta el commit. Montamos una transacción que tardaba 5 segundos en confirmarse y, con database, el job se procesó justo después del commit sin ningún error. Con Redis, en cambio, falló con ModelNotFoundException antes de que la transacción terminara.
Aun usando database, conviene hacerlo bien desde el principio, porque el día que se pase a Redis el problema aparecerá sin que nadie haya tocado ese código. La solución es despachar el job cuando la transacción se haya confirmado:
SendOrderConfirmation::dispatch($order)->afterCommit();También podemos hacer que el job se comporte siempre así implementando ShouldQueueAfterCommit en lugar de ShouldQueue, o activarlo para toda la conexión con 'after_commit' => true en config/queue.php. Si la transacción hace rollback, el job se descarta (también lo comprobamos).
Evitar duplicados y solapes
Laravel tiene varias herramientas para controlar cuántas veces y cuándo se ejecuta un job. Todas guardan sus locks en la caché, así que con varios servidores vuelve a hacer falta una caché compartida.
Con ShouldBeUnique, Laravel no encola un job si ya hay otro igual pendiente o ejecutándose. Viene bien para cosas como "recalcular las estadísticas de este cliente", donde tener diez en la cola no aporta nada.
En Laravel 13 ha llegado además el debounce, que en lugar de quedarse con el primer job se queda con el último. Si un producto se edita cinco veces en 30 segundos, solo se ejecuta la última actualización del índice de búsqueda:
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
public function __construct(public int $productId) {}
public function debounceId(): string
{
return (string) $this->productId;
}
}En las pruebas despachamos el job cinco veces seguidas para el mismo producto y solo se ejecutó el último. Lo que hay que tener en cuenta es que el job se retrasa: no se ejecuta hasta que pasan 30 segundos sin despachos nuevos. Si se siguiera despachando sin parar, no llegaría a ejecutarse nunca, y para eso está el argumento maxWait (con #[DebounceFor(30, maxWait: 120)] no se retrasará más de dos minutos).
Para que dos jobs con la misma clave no se ejecuten a la vez, por ejemplo dos sincronizaciones del mismo cliente, tenemos el middleware WithoutOverlapping:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [new WithoutOverlapping($this->customer->id)];
}Y si el job llama a una API externa con límite de peticiones, el middleware RateLimited usa los rate limiters de Laravel para no pasarse.
Monitorización
Además de los fallos, interesa saber si la cola se está atascando. Para eso Laravel trae queue:monitor, que lanza el evento QueueBusy cuando una cola supera un número de jobs pendientes:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('queue:monitor database:default,database:high --max=100')->everyMinute();// AppServiceProvider
use App\Notifications\QueueIsBusy;
use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
Event::listen(function (QueueBusy $event) {
Notification::route('mail', 'dev@example.com')
->notify(new QueueIsBusy($event->connectionName, $event->queue, $event->size));
});Si queremos algo más visual, con Redis la opción natural es Horizon: un panel con tiempos, rendimiento y jobs fallidos que además gestiona los workers por sí mismo (en Supervisor se ejecuta php artisan horizon en lugar de queue:work y los procesos se configuran en config/horizon.php). Pulse muestra el rendimiento de las colas junto al resto de la aplicación en vuestro propio servidor, y Nightwatch es el servicio de monitorización de pago de Laravel.
Por último, si en algún mantenimiento necesitamos parar el procesamiento sin apagar los workers, tenemos queue:pause y queue:resume:
php artisan queue:pause database:default
php artisan queue:resume database:default
El scheduler
Varias tareas de mantenimiento de las colas se programan con el scheduler de Laravel, que necesita una entrada en el cron del servidor:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1Como mínimo, conviene programar la limpieza de los jobs fallidos antiguos para que la tabla no crezca sin control:
// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->daily();Si el cron se ejecuta en varios servidores, hay que añadir ->onOneServer() a las tareas que no deban ejecutarse en todos a la vez.
Checklist para producción
Para terminar, la lista que repasamos nosotros antes de dar por buena la configuración de colas de un proyecto:
- Driver elegido y tablas jobs y failed_jobs creadas (o Redis configurado).
- Workers gestionados por Supervisor, systemd o el orquestador de contenedores, con el mismo usuario que PHP en la web.
- --max-time o --max-jobs para reciclar los workers.
- queue:restart (o horizon:terminate) al final de cada despliegue, con una caché compartida si hay varios servidores.
- timeout menor que retry_after, y stopwaitsecs por encima de ambos.
- tries, backoff y timeout HTTP en los jobs que llaman a servicios externos.
- afterCommit en los jobs que se despachan dentro de transacciones.
- Avisos cuando falla un job y cuando una cola se atasca, y queue:prune-failed programado.
Conclusión
Casi todos los problemas que hemos visto con colas en producción vienen de que un worker es un proceso que vive mucho tiempo. Cuesta un poco acostumbrarse, pero después casi todo es configuración. Y si algo no se comporta como dice la documentación, lo mejor es reproducirlo en local con dos workers y un job de prueba.
Fuentes
Documentación oficial de Laravel: Queues
Documentación oficial de Laravel: Task Scheduling
Documentación oficial de Laravel: Horizon
Documentación de Supervisor