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:

  1. El trabajo (job) — una clase con lo que hay que hacer.
  2. La cola — donde se guardan los trabajos pendientes.
  3. 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:work a 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.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp