Lección 34 de 45 18 min de lectura

Colas y Jobs

Las colas permiten diferir tareas pesadas para ejecutarlas en segundo plano: enviar emails, procesar imágenes, generar reportes o llamar APIs externas. El usuario no tiene que esperar y tu aplicación responde más rápido.

¿Por qué usar colas?

Imagina que cuando un usuario sube una imagen quieres generar varias miniaturas. Sin colas, el usuario espera hasta que termine el proceso:

php
<?php

// Sin colas: el usuario espera mientras se procesan las imágenes
public function store(Request $request): RedirectResponse
{
    $path = $request->file('image')->store('images');

    // Esto puede tardar varios segundos...
    Image::make(storage_path("app/{$path}"))
        ->resize(800, 600)->save(storage_path('app/thumbnails/large/' . basename($path)));

    Image::make(storage_path("app/{$path}"))
        ->resize(400, 300)->save(storage_path('app/thumbnails/medium/' . basename($path)));

    Image::make(storage_path("app/{$path}"))
        ->resize(150, 150)->save(storage_path('app/thumbnails/small/' . basename($path)));

    return redirect()->route('images.index');
}

Con colas, el usuario recibe respuesta inmediata y las miniaturas se generan en segundo plano:

php
<?php

// Con colas: respuesta inmediata, procesamiento en segundo plano
public function store(Request $request): RedirectResponse
{
    $path = $request->file('image')->store('images');

    ProcessImageThumbnails::dispatch($path);

    return redirect()->route('images.index')
        ->with('success', 'Imagen subida. Las miniaturas se generarán en breve.');
}

Configurar el driver de colas

Laravel soporta varios drivers de colas. El más común en producción es database (usa tu base de datos) o redis (más rápido, requiere Redis). Para desarrollo puedes usar sync (ejecuta inmediatamente) o database.

Configura el driver en .env:

env
QUEUE_CONNECTION=database

Si usas el driver database, necesitas crear la tabla de jobs:

bash
php artisan make:queue-table
php artisan migrate

Crear un Job

Un job es una clase que encapsula una tarea que se ejecutará en segundo plano:

bash
php artisan make:job ProcessImageThumbnails
php
<?php

declare(strict_types=1);

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class ProcessImageThumbnails implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $imagePath
    ) {}

    public function handle(): void
    {
        Log::info("Procesando miniaturas para: {$this->imagePath}");

        // Aquí iría la lógica de procesamiento de imágenes
        // Por ahora solo simulamos el proceso
    }
}

La interfaz ShouldQueue indica que el job debe ejecutarse en una cola. El trait Queueable proporciona métodos útiles como onQueue() y delay().

Despachar un Job

Para enviar un job a la cola, usa el método dispatch():

php
<?php

use App\Jobs\ProcessImageThumbnails;

// Despachar inmediatamente a la cola
ProcessImageThumbnails::dispatch($imagePath);

// Despachar con retraso (ejecutar en 5 minutos)
ProcessImageThumbnails::dispatch($imagePath)->delay(now()->addMinutes(5));

// Despachar a una cola específica
ProcessImageThumbnails::dispatch($imagePath)->onQueue('images');

Ejecutar el worker

Los jobs en cola no se ejecutan solos. Necesitas un worker que procese la cola:

bash
# Ejecutar el worker (mantén esto corriendo)
php artisan queue:work

# Procesar solo un job y terminar
php artisan queue:work --once

# Procesar una cola específica
php artisan queue:work --queue=images

# Con más información de depuración
php artisan queue:work -v

Importante: En producción, el worker debe ejecutarse como un servicio (supervisor, systemd) para que se reinicie automáticamente si falla.

Pasar datos al Job

Puedes pasar cualquier dato serializable al constructor del job. Los modelos Eloquent se serializan automáticamente por su ID:

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Order $order,
        public User $user
    ) {}

    public function handle(): void
    {
        // $this->order y $this->user se recuperan
        // automáticamente de la base de datos
        Mail::to($this->user)->send(new OrderConfirmationMail($this->order));
    }
}
php
<?php

// Despachar con modelos
SendOrderConfirmation::dispatch($order, $user);

Reintentos y fallos

Puedes configurar cuántas veces se reintenta un job que falla:

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CallExternalApi implements ShouldQueue
{
    use Queueable;

    // Número máximo de intentos
    public int $tries = 3;

    // Segundos de espera entre intentos (backoff exponencial)
    public array $backoff = [10, 60, 300];

    // Tiempo máximo de ejecución en segundos
    public int $timeout = 120;

    public function handle(): void
    {
        // Si falla, se reintenta según la configuración
        $response = Http::timeout(30)->get('https://api.example.com/data');

        if ($response->failed()) {
            throw new \Exception('API no disponible');
        }
    }

    // Se ejecuta cuando el job falla definitivamente
    public function failed(\Throwable $exception): void
    {
        Log::error('Job CallExternalApi falló', [
            'error' => $exception->getMessage(),
        ]);
    }
}

Jobs únicos

Puedes evitar que se encolen jobs duplicados implementando ShouldBeUnique:

php
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\User;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class GenerateUserReport implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(
        public User $user
    ) {}

    // La clave de unicidad: solo un job por usuario
    public function uniqueId(): string
    {
        return (string) $this->user->id;
    }

    // Tiempo que se mantiene el lock de unicidad (segundos)
    public int $uniqueFor = 3600;

    public function handle(): void
    {
        // Generar reporte...
    }
}

Si intentas despachar un job mientras otro con el mismo uniqueId está en cola o ejecutándose, el nuevo job se descarta silenciosamente.

Encadenar Jobs

Puedes ejecutar jobs en secuencia usando cadenas:

php
<?php

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new ProcessPodcast($podcast),
    new OptimizePodcast($podcast),
    new ReleasePodcast($podcast),
])->dispatch();

// Si algún job falla, los siguientes no se ejecutan

Batches de Jobs

Para procesar muchos jobs y saber cuándo terminan todos, usa batches:

bash
# Crear tabla para batches
php artisan make:queue-batches-table
php artisan migrate
php
<?php

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$users = User::all();

$jobs = $users->map(fn (User $user) => new SendNewsletterEmail($user));

$batch = Bus::batch($jobs)
    ->then(function (Batch $batch) {
        Log::info('Todos los emails enviados correctamente');
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        Log::error('Algún email falló: ' . $e->getMessage());
    })
    ->finally(function (Batch $batch) {
        Log::info('Batch completado');
    })
    ->dispatch();

// Obtener el progreso
$batch->progress(); // 0-100

Jobs fallidos

Cuando un job falla después de todos los reintentos, se guarda en la tabla failed_jobs:

bash
# Ver jobs fallidos
php artisan queue:failed

# Reintentar un job fallido específico
php artisan queue:retry 5

# Reintentar todos los jobs fallidos
php artisan queue:retry all

# Eliminar un job fallido
php artisan queue:forget 5

# Eliminar todos los jobs fallidos
php artisan queue:flush

Resumen

  • Las colas permiten ejecutar tareas en segundo plano sin hacer esperar al usuario
  • Un job es una clase que implementa ShouldQueue
  • Usa dispatch() para enviar jobs a la cola
  • El worker (queue:work) procesa los jobs encolados
  • Configura $tries y $backoff para manejar fallos
  • Implementa ShouldBeUnique para evitar jobs duplicados
  • Usa chains para jobs secuenciales y batches para procesar muchos jobs

Ejercicios

Ejercicio 1: Job básico de envío de email

Crea un job SendWelcomeEmail que reciba un usuario y simule el envío de un email de bienvenida usando Log::info. Configúralo para que se reintente 3 veces si falla.

Ver solución
php artisan make:job SendWelcomeEmail
<?php

// app/Jobs/SendWelcomeEmail.php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public array $backoff = [10, 30, 60];

    public function __construct(
        public User $user
    ) {}

    public function handle(): void
    {
        Log::info('Enviando email de bienvenida', [
            'user_id' => $this->user->id,
            'email' => $this->user->email,
            'name' => $this->user->name,
        ]);
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('Fallo al enviar email de bienvenida', [
            'user_id' => $this->user->id,
            'error' => $exception->getMessage(),
        ]);
    }
}
<?php

// Uso en el controlador
use App\Jobs\SendWelcomeEmail;

public function register(Request $request): RedirectResponse
{
    $user = User::create($validated);

    SendWelcomeEmail::dispatch($user);

    return redirect()->route('dashboard');
}

Ejercicio 2: Job con retraso

Crea un job SendReminderEmail que se ejecute 24 horas después de ser despachado. El job debe recibir un pedido (Order) y registrar en el log un recordatorio de que el pedido está pendiente de pago.

Ver solución
php artisan make:job SendReminderEmail
<?php

// app/Jobs/SendReminderEmail.php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class SendReminderEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Order $order
    ) {}

    public function handle(): void
    {
        // Verificar si el pedido sigue pendiente de pago
        if ($this->order->status !== 'pending') {
            Log::info('Pedido ya no está pendiente, omitiendo recordatorio', [
                'order_id' => $this->order->id,
                'status' => $this->order->status,
            ]);

            return;
        }

        Log::info('Enviando recordatorio de pago pendiente', [
            'order_id' => $this->order->id,
            'user_email' => $this->order->user->email,
            'total' => $this->order->total,
        ]);
    }
}
<?php

// Uso en el controlador
use App\Jobs\SendReminderEmail;

public function store(Request $request): RedirectResponse
{
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $request->total,
        'status' => 'pending',
    ]);

    // Programar recordatorio para 24 horas después
    SendReminderEmail::dispatch($order)->delay(now()->addHours(24));

    return redirect()->route('orders.show', $order);
}

Ejercicio 3: Job único por usuario

Crea un job GenerateMonthlyReport que genere un reporte mensual para un usuario. Debe ser único por usuario (no se pueden encolar dos reportes para el mismo usuario) y el lock debe mantenerse durante 1 hora.

Ver solución
php artisan make:job GenerateMonthlyReport
<?php

// app/Jobs/GenerateMonthlyReport.php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\User;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class GenerateMonthlyReport implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    // Lock de unicidad: 1 hora
    public int $uniqueFor = 3600;

    public function __construct(
        public User $user,
        public string $month
    ) {}

    // Clave de unicidad: solo un reporte por usuario
    public function uniqueId(): string
    {
        return (string) $this->user->id;
    }

    public function handle(): void
    {
        Log::info('Generando reporte mensual', [
            'user_id' => $this->user->id,
            'month' => $this->month,
        ]);

        // Simular generación del reporte (proceso largo)
        sleep(5);

        Log::info('Reporte mensual generado', [
            'user_id' => $this->user->id,
            'month' => $this->month,
        ]);
    }
}
<?php

// Uso en el controlador
use App\Jobs\GenerateMonthlyReport;

public function generateReport(User $user): RedirectResponse
{
    $month = now()->format('Y-m');

    // Si ya hay un reporte en cola para este usuario,
    // este dispatch se ignora silenciosamente
    GenerateMonthlyReport::dispatch($user, $month);

    return redirect()->back()
        ->with('success', 'Reporte en proceso de generación.');
}

¿Quieres dominar Jobs y Queues a fondo?

Aprende procesamiento asíncrono avanzado, workers en producción, Laravel Horizon y más en nuestro curso especializado.

Ver curso de Jobs y Queues