Icono del sitio IT&ライフハックブログ|学びと実践のためのアイデア集

[Guía completa y probada en campo] Diseño de colas en Laravel y procesamiento asíncrono — Jobs/Queues/Horizon, reintentos e idempotencia, retrasos y prioridades, aislamiento de fallos, integraciones con APIs externas, notificaciones al usuario y una UI de progreso accesible

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

[Guía completa y probada en campo] Diseño de colas en Laravel y procesamiento asíncrono — Jobs/Queues/Horizon, reintentos e idempotencia, retrasos y prioridades, aislamiento de fallos, integraciones con APIs externas, notificaciones al usuario y una UI de progreso accesible

Qué aprenderás (puntos clave)

  • Cómo decidir qué debería ir a la cola y qué puede seguir siendo síncrono
  • Arquitectura central de Jobs/Queues en Laravel y cómo elegir entre Redis y el driver de base de datos
  • Reintentos (tries/backoff), timeouts y patrones operativos “tipo dead-letter” para aislar fallos
  • Cómo evitar la doble ejecución con idempotencia e integrar APIs externas de forma segura
  • Cómo usar prioridades/división de colas, jobs diferidos, batches y cadenas de jobs en proyectos reales
  • Monitorización/alertas y operación de workers en Horizon (reinicios, escalado)
  • Una UI accesible para el “tiempo de espera” en flujos asíncronos (role="status", aria-live, señales no basadas solo en color, rutas de reintento)

Público objetivo (¿a quién beneficia?)

  • Ingenieros Laravel principiante–intermedio: quieren encolar emails/exports, pero temen fallos y ejecuciones dobles
  • Líderes técnicos / responsables de ops: quieren evitar llegar tarde a retrasos/fallos de jobs y a la respuesta a incidentes
  • PM/CS: quieren notificaciones claras de progreso/finalización para reducir tickets y frustración
  • Roles de diseño/QA/accesibilidad: quieren un sistema consistente, “entendible por cualquiera”, para estados de espera/finalización/fallo

Nivel de accesibilidad: ★★★★★

El trabajo asíncrono introduce inherentemente “espera”, por lo que el impacto en accesibilidad es grande. Esta guía estandariza mensajes de progreso/resultado con patrones concretos para que los flujos puedan completarse con lectores de pantalla y navegación por teclado.


1. Introducción: las colas no son solo “velocidad” — son “resiliencia”

En Laravel, las colas no sirven solo para que las páginas se sientan más rápidas. El valor real está en desacoplar trabajo pesado o inestable del ciclo de vida de la solicitud y convertirlo en algo que pueda reintentarse, aislarse cuando falla y operarse con calma.

La entrega de emails, generación de PDFs, exports CSV, llamadas a APIs externas, indexación de búsqueda y agregaciones suelen ser menos estables si se hacen de forma síncrona: los usuarios esperan, las solicitudes expiran y las causas de fallo son más difíciles de ver. Un diseño sólido de colas aumenta las tasas de éxito, acelera el triage y hace que las operaciones se sientan predecibles.


2. Cuándo encolar (si no estás seguro, empieza aquí)

Considera encolar si se cumple cualquiera de lo siguiente:

  • La tarea es pesada (segundos+, intensiva en CPU/memoria)
  • Depende de redes (APIs externas, email) y falla de forma intermitente
  • Es un trabajo masivo (exports grandes, actualizaciones por lotes)
  • Puede terminar más tarde sin romper el UX (decenas de segundos a minutos)
  • Quieres reintentos + aislamiento de fallos (un diseño “recuperable”)

Qué debería permanecer síncrono:

  • Las escrituras mínimas requeridas para que la página/acción se complete (p. ej., crear el pedido en sí)
  • Operaciones donde el usuario debe ver el resultado de inmediato (pero el trabajo extra en segundo plano puede seguir yendo a la cola)

Un patrón realista es: “la escritura principal es síncrona; notificaciones/agregación/integraciones son asíncronas.”


3. La base: elección de driver y configuración básica

3.1 Cómo pensar en los drivers

  • Redis: rápido y común; excelente con Horizon
  • Base de datos: fácil para empezar; puede no escalar tan bien con carga alta
  • Colas cloud (SQS, etc.): simplicidad operativa, pero requiere diseño cuidadoso, costes y planificación de observabilidad

Para un SaaS pequeño–mediano, Redis + Horizon suele ser el inicio más práctico: baja barrera y los retrasos se vuelven visibles rápido.

3.2 Configuración básica (ejemplo)

  • .env: QUEUE_CONNECTION=redis
  • config/queue.php: definir nombres de colas, intervalos de reintento, política de retención de jobs fallidos
  • Preparar el almacenamiento de jobs fallidos (failed_jobs), según la configuración elegida

4. Implementación mínima de un job: divide en unidades manejables

Mantener los jobs cerca de “un job = un propósito” hace que reintentos y fallos sean más fáciles de interpretar.

// app/Jobs/SendWelcomeMail.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use App\Models\User;
use App\Mail\WelcomeMail;

class SendWelcomeMail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $userId) {}

    public $tries = 5;
    public $timeout = 60;

    public function backoff(): array
    {
        return [10, 30, 60, 120, 300];
    }

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        Mail::to($user->email)->send(new WelcomeMail($user));
    }
}

Notas

  • Pasar un id es más seguro que pasar el modelo completo (serialización y resiliencia ante cambios de estado).
  • Hacer explícitos tries/timeout/backoff suele estabilizar operaciones de un día para otro.
  • No “tragues” excepciones: deja que fallen para que la monitorización las detecte.

5. Diseño de reintentos: es más fuerte cuando clasificas los fallos

No todos los fallos son iguales. En la práctica, dividirlos en dos tipos acelera decisiones:

  • Fallos transitorios (inestabilidad de red, problemas temporales de una API externa)
    • los reintentos suelen funcionar
  • Fallos permanentes (entrada inválida, destino borrado, permisos)
    • reintentar suele ser inútil; aísla para intervención humana

5.1 Falla rápido ante patrones de fallo permanente

Si una API externa devuelve 4xx, los reintentos a menudo no ayudarán. Ramifica por tipo de excepción/estado y “abandona pronto” en vez de quemar tiempo de workers.

5.2 Timeouts: la regla práctica

  • Demasiado corto → baja la tasa de éxito
  • Demasiado largo → se atascan workers y los retrasos se encadenan

Un punto de partida seguro es ~2× tu p95 normal para ese job, y luego ajustar según el retraso observado.


6. Idempotencia: evita la doble ejecución “por diseño”

Los jobs en cola pueden ejecutarse más de una vez por:

  • reintentos
  • reinicios de workers
  • re-encolado tras timeouts
  • problemas de red

Si no diseñas para esto, verás incidentes reales: “dos emails”, “doble cobro”, “dobles puntos”.

6.1 Patrones comunes de idempotencia

  • Asignar un idempotency_key por job
  • Evitar ejecución concurrente/doble vía Cache::lock() o restricciones únicas en DB
  • Si ya se completó, no hacer nada y salir

Ejemplo: evitar enviar el mismo email de factura dos veces

public function handle(): void
{
    $key = "invoice_mail:{$this->invoiceId}";
    $lock = cache()->lock($key, 120);

    if (!$lock->get()) {
        return; // ya está corriendo (o se ejecutó hace poco)
    }

    try {
        $invoice = Invoice::findOrFail($this->invoiceId);

        if ($invoice->mail_sent_at) {
            return; // ya enviado → no-op (idempotente)
        }

        Mail::to($invoice->user->email)->send(new InvoiceMail($invoice));

        $invoice->forceFill(['mail_sent_at' => now()])->save();
    } finally {
        $lock->release();
    }
}

Puntos clave

  • Persistir un flag “sent” en DB te vuelve resiliente ante reintentos y replays.
  • Los locks de caché ayudan, pero la autoridad final debería ser estado durable (DB) cuando sea posible.

7. División de colas y prioridad: detén las cascadas de retraso

Con una sola cola, un job pesado puede bloquear jobs ligeros. Un enfoque probado en campo es dividir colas por propósito:

  • high: cerca de acciones del usuario (notificaciones, integraciones ligeras, urgente)
  • default: trabajo normal
  • low: trabajo pesado (agregación, indexación, exports)
SendWelcomeMail::dispatch($user->id)->onQueue('high');
RecalcDailyUsage::dispatch($tenantId)->onQueue('low');

Ejecuta workers por cola para localizar retrasos y hacer más visible dónde están los cuellos de botella.


8. Retrasos, cadenas y lotes: cuándo usar cada uno

8.1 Jobs diferidos (delay)

Bueno para “reintentar más tarde” o “notificación de seguimiento más tarde”.

SendFollowUpMail::dispatch($user->id)->delay(now()->addMinutes(10));

8.2 Cadenas (chain)

Bueno cuando el orden importa (generar → subir → notificar).

Bus::chain([
  new GenerateReport($reportId),
  new UploadReport($reportId),
  new NotifyReportReady($reportId),
])->dispatch();

8.3 Lotes (batch)

Lo mejor para conjuntos grandes de jobs cuando quieres progreso global y manejo agregado de fallos—especialmente exports y actualizaciones masivas.


9. Patrón de integración con APIs externas: timeouts, reintentos y fallbacks

Las APIs externas fallan. Precisamente por eso combinan bien con colas.

  • define un timeout relativamente corto
  • fija cantidad de reintentos y espaciado
  • aísla fallos + ofrece una vía de recuperación manual
  • usa fallback cuando sea posible (valor cacheado, reintentar luego)

El cliente HTTP de Laravel funciona bien dentro de jobs:

$res = \Illuminate\Support\Facades\Http::timeout(10)
  ->retry(3, 200)
  ->post($url, $payload);

if ($res->failed()) {
  throw new \RuntimeException('external api failed');
}

“Lanzar excepción ante fallo” suele ser más simple en un job, porque reintentos y aislamiento son trabajo de la cola.


10. Operación de jobs fallidos: aísla y hazlo visible

Las colas tratan menos de “cero fallos” y más de “fallos recuperables”. Necesidades operativas clave:

  • detectar crecimiento de jobs fallidos (alertas)
  • rastrear razones (excepción, nombre del job, IDs objetivo, trace ID)
  • tener un procedimiento de replay (cuándo/cómo reintentar; cuándo corregir datos)
  • convertir fallos permanentes en mejoras de producto o mensajes al usuario

Incluye al menos estos campos en logs de fallo:

  • IDs objetivo (userId, orderId, etc.)
  • tenant ID (si es multi-tenant)
  • trace_id (si se originó en una request)
  • resumen de respuesta de la API externa (enmascara info sensible)

11. Monitorización con Horizon: solo la visibilidad ya compra tranquilidad

Con Horizon (basado en Redis) puedes ver:

  • throughput por cola
  • fallos
  • tiempo de espera (delay)
  • estado de workers

Indicadores operativos útiles:

  • queue_wait_time (si sube, hay riesgo de impacto al usuario)
  • picos de tasa de fallos (caídas externas o problemas de despliegue)
  • aumento de duración de jobs (señal temprana de atasco)

Empieza con pocas alertas:

  • pico súbito de fallos
  • tiempo de espera por encima de umbral
  • worker caído

Normalmente con eso alcanza para mantener operaciones controlables.


12. Notificaciones al usuario y una UI de progreso accesible: elimina la “confusión” del asíncrono

Desde la vista del usuario, lo asíncrono a menudo parece “hice clic y no pasó nada”.
Arreglar esto mejora UX y reduce soporte.

12.1 Mínimo 3 estados

  • Inicio: confirmado
  • En progreso: procesando (si hace falta)
  • Hecho/Fallo: resultado + siguiente acción

12.2 Patrón UI estándar (Inicio → Hecho)

@if(session('status'))
  <div role="status" aria-live="polite" class="border p-3 mb-4">
    {{ session('status') }}
  </div>
@endif

@if(session('error'))
  <div role="alert" class="border p-3 mb-4">
    {{ session('error') }}
  </div>
@endif

Ejemplo de mensaje de inicio (export)

  • “Exportación iniciada. Te avisaremos cuando esté completa.”
  • Si es posible, añade una expectativa como “Suele completarse en unos minutos”, pero evita prometer de más.

12.3 Progreso (polling/eventos): reglas prácticas de accesibilidad

  • muestra progreso numérico en texto (p. ej., “40%”)
  • usa aria-live="polite" y evita actualizaciones demasiado frecuentes
  • no dependas solo del color del spinner
  • mantén rutas por teclado para “Cancelar” o “Volver”

Ejemplo (concepto):

<div id="progress" role="status" aria-live="polite">Preparando…</div>

12.4 UX de fallos (crítico)

Si la UI solo dice “Error”, el usuario queda bloqueado. Proporciona:

  • reintentar (botón/enlace)
  • alternativas (archivo más pequeño, rango de fechas más acotado)
  • un ID de soporte/referencia (trace_id, etc.)

Esos tres reducen la frustración de forma dramática.


13. Testing: trata las colas como “especificaciones” con fakes

En vez de ejecutar colas en tests, estabiliza el comportamiento con Queue::fake() y afirma el dispatch.

use Illuminate\Support\Facades\Queue;

public function test_export_dispatches_job()
{
    Queue::fake();

    $user = User::factory()->create();
    $this->actingAs($user);

    $this->post('/export', ['range' => 'last_30_days'])
        ->assertRedirect()
        ->assertSessionHas('status');

    Queue::assertPushed(\App\Jobs\ExportCsv::class);
}

Para APIs externas, usa Http::fake() para modelar éxito/fallo y evitar regresiones en la política de reintentos.


14. Errores comunes y cómo evitarlos

  • Los workers se atascan porque los jobs son demasiado pesados
    • Solución: dividir colas, dividir granularidad de jobs, ajustar timeouts, “materializar” agregación
  • La doble ejecución duplica emails/pagos
    • Solución: claves de idempotencia, flags de “enviado”, locks
  • Los fallos son invisibles hasta que se acumulan
    • Solución: Horizon + alertas + logs estructurados
  • La inestabilidad de API externa provoca “tormentas” de reintentos
    • Solución: limitar reintentos, backoff, clasificar fallos permanentes, introducir amortiguación tipo circuit-breaker gradualmente
  • Los usuarios no saben qué pasó
    • Solución: mensajes de inicio/hecho/fallo, role="status", rutas de reintento

15. Checklist (compartible)

Diseño

  • [ ] Se identificaron cargas para encolar (pesadas/inestables/tolerantes a retraso)
  • [ ] La granularidad del job es apropiada (1 job ≈ 1 propósito)
  • [ ] Existe idempotencia (checks de enviado/completado)
  • [ ] La división de colas (high/default/low) localiza retrasos

Operaciones

  • [ ] tries/backoff/timeout son explícitos
  • [ ] Los jobs fallidos son visibles (monitorización/alertas/logging)
  • [ ] El procedimiento de replay está documentado (condiciones, responsable)
  • [ ] Las APIs externas tienen política de timeout/reintento

UX/Accesibilidad

  • [ ] Inicio/hecho/fallo se explican en texto
  • [ ] role="status"/aria-live se usan cuando corresponde
  • [ ] El fallo ofrece reintento/alternativas/ID de soporte
  • [ ] Los indicadores de progreso no dependen solo del color

Testing

  • [ ] Queue::fake() fija el comportamiento de dispatch
  • [ ] Las APIs externas usan Http::fake() para éxito/fallo
  • [ ] Los jobs críticos testean comportamiento ante fallos (aislamiento/notificaciones)

16. Cierre

Las colas en Laravel son una forma poderosa de conseguir “velocidad” y “resiliencia” mediante ejecución asíncrona. La clave no es confiar solo en reintentos: evita duplicados con idempotencia, localiza retrasos con división de colas y logra operaciones tranquilas con Horizon y alertas. Y como lo asíncrono introduce espera, debes comunicar con claridad al usuario: estados de inicio/hecho/fallo y patrones de progreso accesibles que funcionen con lectores de pantalla y teclado. Empieza encolando un flujo con cuidado—export, email o integración con una API externa—y construye desde ahí.


Referencias

Salir de la versión móvil