[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=redisconfig/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
ides más seguro que pasar el modelo completo (serialización y resiliencia ante cambios de estado). - Hacer explícitos
tries/timeout/backoffsuele 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_keypor 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 normallow: 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
timeoutrelativamente 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
- Solución: mensajes de inicio/hecho/fallo,
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/timeoutson 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-livese 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
- Documentación oficial de Laravel
- Conceptos de fiabilidad y operaciones
- Accesibilidad
