Curso de Laravel
Colas y trabajos en Laravel: procesar tareas en segundo plano
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Hoy vemos una de las funcionalidades que más cambia la sensación de un sistema: las colas.
El problema es este. El usuario pulsa «Guardar» y el sistema tiene que enviar un correo, generar un PDF y avisar a una API externa. Cada cosa tarda unos segundos, y mientras tanto la página está cargando y el usuario no sabe si funcionó.
Con colas, el usuario recibe la respuesta al instante y el trabajo pesado ocurre después, por detrás.
¡Empecemos!
Cómo funciona
Son tres piezas:
- El trabajo (job) — una clase con lo que hay que hacer.
- La cola — donde se guardan los trabajos pendientes.
- El trabajador (worker) — un proceso que los saca y los ejecuta.
Tu aplicación deja el trabajo en la cola y responde. El trabajador, que corre aparte, lo recoge cuando le toca.
Configurar la cola
En .env:
QUEUE_CONNECTION=database
Las opciones principales:
sync— sin cola: se ejecuta al instante, en la misma petición. Es el valor por defecto.database— los trabajos se guardan en una tabla.redis— mucho más rápido; lo habitual en producción con volumen.
Empieza con database. Funciona en cualquier hosting, es fácil de inspeccionar —los trabajos pendientes se ven en una tabla— y aguanta perfectamente el volumen de un sistema de gestión normal.
Y una ventaja de sync que conviene aprovechar: puedes escribir todo el código con colas desde el principio y dejar sync en local. Todo funciona igual, sin trabajador, y el día que lo cambies a database no tocas una línea.
php artisan make:queue-table
php artisan migrate
Crear un trabajo
php artisan make:job GenerarReporteMensual
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerarReporteMensual implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 300;
public function __construct(
public User $usuario,
public int $mes,
public int $anio,
) {}
public function handle(GeneradorPdf $pdf): void
{
$datos = Cita::whereMonth('fecha', $this->mes)
->whereYear('fecha', $this->anio)
->with('paciente', 'doctor')
->get();
$ruta = $pdf->generar('reportes.mensual', compact('datos'));
Mail::to($this->usuario)->send(new ReporteListo($ruta));
}
public function failed(\Throwable $e): void
{
Log::error("Falló el reporte de {$this->usuario->email}: {$e->getMessage()}");
}
}
Dos cosas a notar.
handle() admite inyección de dependencias. Ese GeneradorPdf llega resuelto por el contenedor, exactamente como vimos en la lección de inyección de dependencias.
Las propiedades del constructor se serializan para guardarlas en la cola. Los modelos Eloquent se guardan solo como identificador y se vuelven a consultar al ejecutarse, así que el trabajo siempre trabaja con datos frescos.
Eso último tiene una consecuencia: si el registro se elimina entre el despacho y la ejecución, el trabajo falla. Es el comportamiento correcto, pero conviene saberlo.
Despachar el trabajo
GenerarReporteMensual::dispatch($usuario, 6, 2026);
Con retraso:
GenerarReporteMensual::dispatch($usuario, 6, 2026)
->delay(now()->addMinutes(10));
En una cola concreta:
GenerarReporteMensual::dispatch($usuario, 6, 2026)->onQueue('reportes');
Y condicionalmente:
GenerarReporteMensual::dispatchIf($usuario->quiere_reportes, $usuario, 6, 2026);
En el controlador:
public function generar(Request $request)
{
GenerarReporteMensual::dispatch($request->user(), $request->mes, $request->anio);
return back()->with('exito',
'Estamos generando el reporte. Te avisaremos por correo cuando esté listo.');
}
Fíjate en el mensaje. No dice «reporte generado», porque todavía no lo está. Decirle al usuario qué va a pasar es parte de hacer esto bien.
Ejecutar el trabajador
php artisan queue:work
Ese proceso se queda escuchando y ejecuta los trabajos según llegan.
php artisan queue:work --queue=urgente,reportes,default
php artisan queue:work --tries=3 --timeout=120
php artisan queue:work --once
php artisan queue:listen
El orden de --queue es la prioridad: vacía urgente antes de tocar reportes.
queue:work carga el código una vez y lo mantiene en memoria. Es rápido, pero significa que al cambiar el código hay que reiniciarlo:
php artisan queue:restart
Ese comando pide a los trabajadores que terminen lo que están haciendo y se apaguen limpiamente, para que el supervisor los levante con el código nuevo.
Es lo que mencionamos en la lección de caché: sin ese paso, despliegas y los trabajadores siguen con la versión anterior indefinidamente.
En desarrollo, queue:listen recarga el código en cada trabajo. Más lento, pero sin reinicios.
Mantenerlo vivo en producción
Un queue:work a mano se muere en cuanto cierras la terminal. Hace falta un supervisor de procesos que lo mantenga corriendo y lo reinicie si se cae.
Con Supervisor, en /etc/supervisor/conf.d/clinica-worker.conf:
[program:clinica-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/clinica/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/clinica/storage/logs/worker.log
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start clinica-worker:*
Ese numprocs=2 levanta dos trabajadores en paralelo. Y --max-time=3600 los recicla cada hora, lo que evita problemas de memoria acumulada en procesos de larga duración.
En hosting compartido no suele haber Supervisor. La alternativa es una tarea programada que ejecute queue:work --stop-when-empty cada minuto. No es ideal, pero funciona. Lo vemos en la lección de tareas programadas.
Trabajos fallidos
php artisan make:queue-failed-table
php artisan migrate
Cuando un trabajo agota sus intentos, se guarda ahí con la excepción completa.
php artisan queue:failed # listar
php artisan queue:retry all # reintentar todos
php artisan queue:retry 5 # reintentar uno
php artisan queue:flush # borrar todos
Esa tabla hay que revisarla. Un trabajo fallido es un correo que nunca salió o un reporte que nadie recibió, y si nadie mira, nadie se entera.
Lo mínimo es una alerta cuando aparezcan fallos:
// AppServiceProvider::boot()
Queue::failing(function (JobFailed $evento) {
Log::critical('Trabajo fallido', [
'job' => $evento->job->resolveName(),
'error' => $evento->exception->getMessage(),
]);
});
Reintentos con espera creciente
Cuando el fallo es temporal —una API caída— reintentar de inmediato no sirve:
public function backoff(): array
{
return [10, 60, 300];
}
Espera 10 segundos, luego 1 minuto, luego 5. Si la API vuelve en ese rato, el trabajo termina bien sin que nadie intervenga.
Y para no reintentar indefinidamente:
public function retryUntil(): \DateTime
{
return now()->addHours(2);
}
Errores que no vale la pena reintentar
Si un dato es inválido, reintentar tres veces da el mismo resultado tres veces:
public function handle(): void
{
if (! $this->paciente->correo) {
$this->fail('El paciente no tiene correo registrado.');
return;
}
// ...
}
fail() marca el trabajo como fallido sin reintentar. También existe release() para devolverlo a la cola manualmente:
if ($this->apiEstaSaturada()) {
$this->release(60); // vuelve a intentarlo en un minuto
return;
}
Evitar trabajos duplicados
Si el usuario pulsa el botón tres veces, se despachan tres reportes idénticos:
use Illuminate\Contracts\Queue\ShouldBeUnique;
class GenerarReporteMensual implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function uniqueId(): string
{
return "reporte-{$this->usuario->id}-{$this->mes}-{$this->anio}";
}
}
Mientras haya uno pendiente con ese identificador, los demás se descartan.
Lotes de trabajos
Para procesar muchos elementos y saber cuándo terminó todo:
use Illuminate\Support\Facades\Bus;
$lote = Bus::batch(
$pacientes->map(fn ($p) => new EnviarRecordatorio($p))->all()
)
->then(fn (Batch $lote) => Log::info("Enviados {$lote->totalJobs} recordatorios"))
->catch(fn (Batch $lote, \Throwable $e) => Log::error('Falló el lote'))
->finally(fn (Batch $lote) => Notification::send($admin, new LoteTerminado($lote)))
->name('Recordatorios diarios')
->dispatch();
Requiere la tabla correspondiente:
php artisan make:queue-batches-table
php artisan migrate
Y permite mostrar el progreso:
$lote = Bus::findBatch($id);
$lote->progress(); // porcentaje
$lote->processedJobs();
$lote->failedJobs();
Con eso puedes tener una barra de progreso real en la interfaz, que para procesos largos es exactamente lo que el usuario quiere ver.
Encadenar trabajos
Cuando el orden importa:
Bus::chain([
new ProcesarPago($venta),
new EmitirFactura($venta),
new EnviarComprobante($venta),
])->dispatch();
Si uno falla, los siguientes no se ejecutan.
El problema de la transacción
Esto lo adelantamos en la lección de transacciones y merece repetirse, porque genera errores muy raros:
DB::transaction(function () use ($datos) {
$venta = Venta::create($datos);
ProcesarVenta::dispatch($venta); // ⚠️
});
El trabajador puede recoger ese trabajo antes de que la transacción se confirme, y no encontrar la venta. El síntoma es un ModelNotFoundException intermitente que no hay manera de reproducir.
La solución, en config/queue.php:
'after_commit' => true,
O caso por caso:
ProcesarVenta::dispatch($venta)->afterCommit();
Actívalo globalmente y olvídate. No hay casi ningún caso donde quieras lo contrario.
Qué encolar
Buenos candidatos:
- Envío de correos y notificaciones
- Generación de PDF y Excel
- Procesado de imágenes
- Llamadas a APIs externas
- Importaciones de archivos grandes
- Cualquier cosa que tarde más de un segundo
Lo que no conviene encolar: lo que el usuario necesita ver de inmediato. Si encolas el guardado de un formulario, el usuario recarga y no ve su registro.
Ver qué está pasando
php artisan queue:monitor default,reportes --max=100
Y con database, simplemente:
SELECT COUNT(*) FROM jobs;
Si esa cuenta crece sin parar, los trabajadores no dan abasto —o están caídos. Es lo primero que reviso cuando alguien reporta que no le llegan los correos.
Errores comunes
- No reiniciar los trabajadores al desplegar.
- Despachar dentro de una transacción sin
after_commit. - No crear la tabla de fallidos y perder el rastro de los errores.
- No revisar los trabajos fallidos nunca.
- Decirle al usuario que algo ya se hizo cuando apenas se encoló.
- Encolar lo que el usuario necesita ver ya.
queue:worka mano en producción, sin supervisor.- Reintentar errores permanentes.
Para cerrar
Las colas son el paso que separa un sistema que se siente lento de uno que responde al instante. Y el cambio en el código es mínimo: dispatch() en lugar de llamar directamente.
Tres cosas que no se pueden olvidar: queue:restart al desplegar, after_commit activado, y revisar la tabla de trabajos fallidos.
En la siguiente lección veremos las tareas programadas, que son el otro lado de esto: código que se ejecuta solo, a una hora fija.
Saludos y éxitos.
Norvic Software
Desarrollamos el software que tu empresa necesita
Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp