
En resumen
- Un cron que procesa lotes es una cola sin nombre: Laravel le da reintentos, visibilidad y control.
- Cada comprobante es un job único e idempotente, con reintentos de espera creciente.
- El límite de peticiones al LLM se declara una vez y lo comparten todos los workers.
- El timeout del job siempre va por debajo del retry_after de la conexión.
- Horizon separa colas por prioridad y ajusta workers solo, con tope de memoria.
La optimización de colas en Laravel empieza con una pregunta incómoda: ¿tu proceso masivo ya es una cola, aunque no lo llames así? Si un cron toma registros pendientes cada pocos minutos y los procesa uno por uno, la respuesta es sí. Solo que sin reintentos, sin visibilidad y sin control.
Lo sé porque construí un sistema así. Gestionó un pool de 80,000 folios en producción y verificaba comprobantes con OCR + LLM, sin revisión manual.
Una precisión antes de seguir: ese sistema no corrió en Laravel. Corrió en WordPress, con plugins PHP propios y un cron cada 2 minutos. Este artículo es cómo traduciría ese mismo pipeline a colas de Laravel, y qué gana en el cambio. El código es ilustrativo.
El punto de partida: un cron que ya era una cola
El sistema es el de Panini Fan Zone FWC 2026. Cada comprobante pasaba por Tesseract para extraer el texto, y después un modelo LLaMA 3.3 70B, servido por Groq, lo validaba contra las reglas de la promoción.
El cron cada 2 minutos funcionaba. Pero un diseño así obliga a resolver a mano cosas que una cola resuelve de fábrica:
- Corridas encimadas. Si una ejecución tarda más que el intervalo, la siguiente arranca encima. Hay que ponerle candado.
- Reintentos. Si el LLM falla por la red, el comprobante se queda pendiente hasta la próxima corrida, sin control de cuántas veces se intentó.
- Visibilidad. ¿Cuántos van en proceso? ¿Cuáles fallaron y por qué? Sin cola, eso se arma con registros propios.
- Un solo carril. Todo comparte la misma fila: un trabajo urgente espera detrás de miles de verificaciones.
En el artículo sobre optimización de Python resolví esos problemas con candados de archivo y lotes acotados. Laravel ofrece las mismas garantías como piezas del framework.
Optimización de colas en Laravel: un job por comprobante
La primera decisión es la granularidad. Un job que procesa 5,000 comprobantes es un cron disfrazado: si falla en el 3,000, el reintento repite todo.
La unidad correcta es un comprobante. Cada job es pequeño, se reintenta solo y su fallo no arrastra a nadie más.
class VerificarComprobante implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 3;
public array $backoff = [10, 60, 300]; // espera creciente: el proveedor se recupera solo
public int $timeout = 90; // siempre por debajo del retry_after de la conexión
public function __construct(public int $comprobanteId) {}
public function uniqueId(): string { return (string) $this->comprobanteId; }
}
Cada línea responde a uno de los problemas del cron.
ShouldBeUnique impide encolar dos veces el mismo comprobante mientras el primero siga pendiente. Si el despachador corre de nuevo antes de tiempo, no hay duplicado ni doble cobro del LLM.
$backoff con un arreglo da una espera distinta por intento. Un error de red a los 10 segundos suele resolverse; uno que persiste se espacia para no martillar al proveedor.
$timeout evita que un OCR atorado retenga un worker para siempre. Y su relación con retry_after merece sección propia, más abajo.
Pasar el identificador y no el modelo completo también es una decisión. El job se serializa en la cola; un ID pesa nada y obliga a leer el estado fresco de la base de datos al ejecutarse.
Proteger al proveedor del LLM
El paso caro y lento es la llamada al modelo. Si subes a 20 workers, haces 20 veces más peticiones por minuto, y el proveedor responde con errores de límite.
La solución es declarar el límite una sola vez y que lo compartan todos los workers, estén en el servidor que estén:
// AppServiceProvider::boot(): un límite compartido por todos los workers.
RateLimiter::for('llm', fn (object $job) => Limit::perMinute(60));
// En el job: protege al proveedor y evita trabajo duplicado.
public function middleware(): array
{
return [
(new WithoutOverlapping($this->comprobanteId))->expireAfter(180),
new RateLimited('llm'),
];
}
El número es un ejemplo; el tuyo sale del contrato con tu proveedor. Lo importante es que vive en un solo lugar.
WithoutOverlapping es la segunda capa de protección. Aunque algo despache el mismo comprobante por otra vía, dos workers no lo procesan a la vez. El expireAfter libera el candado si un worker muere a medio proceso.
Un detalle que muerde: cuando RateLimited frena un job, lo libera de vuelta a la cola y eso cuenta como intento. Con un límite estricto, tres intentos se agotan rápido. Sube $tries o usa retryUntil() para fijar una ventana de tiempo en lugar de un número de intentos.
Despachar en lotes acotados
El cron no desaparece. Cambia de rol: ya no procesa, solo despacha. Y lo hace en lotes con nombre, para poder seguir su avance.
// ⚠️ Producción: respalda la tabla antes de cambiar el flujo de estados.
$ids = Comprobante::where('estado', 'pendiente')
->orderBy('id')->limit(5000)->pluck('id'); // corrida acotada
Bus::batch($ids->map(fn ($id) => new VerificarComprobante($id)))
->name('verificacion-'.now()->format('YmdHi'))
->onQueue('verificacion')
->allowFailures() // un comprobante malo no cancela el lote
->finally(fn (Batch $b) => Log::info('lote', ['fallidos' => $b->failedJobs]))
->dispatch();
Para usar lotes, el job necesita además el trait Batchable y la tabla de lotes que crea Laravel con su migración.
allowFailures() es clave en procesamiento masivo. Sin él, el primer comprobante ilegible cancela los demás trabajos del lote. Con él, los fallos se registran y el resto sigue.
El tope de 5,000 cumple la misma función que en el cron original: cada corrida tiene un tamaño conocido. Lo que no alcance lo toma la siguiente.
Mejores prácticas para procesamiento masivo
El timeout siempre por debajo de retry_after
Es la configuración que más incidentes causa. En config/queue.php, cada conexión tiene un retry_after: el tiempo que Laravel espera antes de dar por perdido un job en proceso y entregarlo a otro worker.
Si un job puede durar 90 segundos y retry_after está en 60, a los 60 segundos otro worker toma el mismo job. Ahora corre dos veces en paralelo, y nadie lo nota hasta ver resultados duplicados.
La regla es simple: retry_after debe ser mayor que el timeout más largo de cualquier job en esa conexión, con margen. Si un tipo de job es mucho más lento que los demás, merece su propia conexión.
Idempotencia, aunque haya unicidad
ShouldBeUnique y WithoutOverlapping reducen los duplicados. No los eliminan. Un worker puede morir después de guardar el resultado y antes de confirmar el job, y la cola lo va a entregar otra vez.
Por eso cada job empieza revisando el estado: si el comprobante ya no está pendiente, termina sin hacer nada. Y el resultado se guarda en una sola operación que deja el mismo estado aunque se ejecute dos veces.
Esa disciplina viene del sistema original. En su módulo de registro, una suite con 50 workers simultáneos contra la misma ruta confirmó que solo un registro ganara cada cupo. La concurrencia se prueba, no se supone.
Colas separadas por prioridad
Una sola cola es un solo carril. Si hay 5,000 verificaciones encoladas y llega un correo de confirmación, espera detrás de todas.
urgentepara lo que un usuario está esperando: confirmaciones, notificaciones, respuestas a una acción.verificacionpara el procesamiento masivo, con su propio límite de workers.mantenimientopara reportes y limpiezas que pueden esperar a la madrugada.
Un worker puede atender varias colas en orden de prioridad, pero la separación real se logra con pools de workers distintos. Eso es justo lo que configura Horizon.
Los fallos son datos
Un job que agota sus intentos termina en la tabla de trabajos fallidos, con la excepción completa. Esa tabla es un tablero de diagnóstico, no un basurero.
- Implementa
failed()en el job. Marca el comprobante como «requiere revisión» para que no se quede pendiente para siempre. - Distingue errores. Una imagen ilegible no se arregla reintentando: falla de inmediato con
fail(). Un timeout de red, sí. - Reintenta con intención.
queue:retrydespués de corregir la causa, no como reflejo. - Poda con política.
queue:prune-failedcon una retención definida evita que la tabla crezca sin límite.
Configuración avanzada de Horizon
Horizon es el supervisor de colas de Laravel para Redis. Su valor no es el panel bonito: es que la configuración de workers vive en código, versionada y revisable.
'production' => [
'urgente' => [
'connection' => 'redis', 'queue' => ['urgente'],
'balance' => 'auto', 'minProcesses' => 1, 'maxProcesses' => 4,
],
'verificacion' => [
'connection' => 'redis', 'queue' => ['verificacion'],
'balance' => 'auto', 'maxProcesses' => 6,
'memory' => 256, 'tries' => 3, 'timeout' => 90, // timeout < retry_after
],
],
Dos supervisores, dos pools. La verificación masiva nunca le quita workers a lo urgente, porque cada uno tiene su propio tope.
balance => 'auto' sube y baja procesos según la carga de cada cola, dentro de minProcesses y maxProcesses. En horas tranquilas consume poco; cuando entra un lote grande, crece hasta el tope.
maxProcesses no se elige por la RAM disponible. Se elige por el límite del proveedor del LLM: más workers que peticiones permitidas solo producen esperas y reintentos.
memory reinicia el worker cuando supera ese consumo en megabytes. Los procesos PHP de larga vida acumulan memoria con el tiempo; un reinicio controlado es mejor que un servidor sin RAM.
Tres ajustes más que conviene revisar:
waitsdefine cuántos segundos de espera en una cola disparan una alerta. Es la señal temprana de que faltan workers.trimcontrola cuánto tiempo guarda Horizon los jobs recientes y fallidos. En procesamiento masivo, guardar todo llena Redis.silencedoculta del panel jobs ruidosos y triviales, para que lo importante no se pierda.
Y una regla de despliegue: después de subir código nuevo, ejecuta php artisan horizon:terminate. Los workers son procesos de larga vida y siguen corriendo el código viejo hasta que se reinician. El supervisor del sistema los levanta de nuevo con la versión actual.
Lo que NO debes hacer
- Un job gigante que procesa miles de registros. Un fallo a la mitad repite todo, el timeout se vuelve imposible de calibrar y no hay visibilidad del avance. Un registro por job, agrupados en lotes.
- Un timeout mayor que
retry_after. El mismo job corre dos veces en paralelo y el problema parece aleatorio. Revisa esa pareja en cada conexión. - Escalar workers para «ir más rápido» contra una API con límite. Más procesos no aumentan las peticiones permitidas; aumentan los errores, los reintentos y el costo. El límite se declara con
RateLimitedy los workers se ajustan a él. - Olvidar reiniciar los workers al desplegar. El código nuevo está en disco, pero los procesos siguen ejecutando el viejo. Los bugs «corregidos» siguen apareciendo.
El siguiente paso
La optimización de colas en Laravel no es subir el número de workers. Es elegir la unidad de trabajo correcta, proteger lo que es caro, calibrar los tiempos y separar lo urgente de lo masivo.
Si tu equipo enfrenta cuellos de botella en procesamiento masivo, colas que se atoran o integraciones con LLM que no terminan de ser confiables, en Conqr Digital Agency diseñamos arquitecturas que escalan. Revisemos tu caso.
El sistema original está documentado en el caso de Panini Fan Zone, y la versión del pipeline en Python, en optimización de Python para 80,000 folios.
Preguntas frecuentes
- ¿Qué diferencia hay entre timeout y retry_after en Laravel?
- El timeout es cuánto puede durar un job antes de que el worker lo mate. El retry_after, en la conexión de la cola, es cuánto espera Laravel antes de considerar perdido un job en proceso y volver a entregarlo. Si retry_after es menor que el timeout, el mismo job puede correr dos veces en paralelo.
- ¿Cuándo usar ShouldBeUnique en un job?
- Cuando despachar dos veces el mismo trabajo sería un error o un gasto: verificar el mismo comprobante, cobrar el mismo pedido, regenerar el mismo reporte. El job declara su identificador único y Laravel no encola un duplicado mientras el primero siga pendiente.
- ¿Necesito Horizon para usar colas en Laravel?
- No. Las colas funcionan con queue:work y un supervisor de procesos. Horizon sirve cuando usas Redis y quieres configurar workers en código, balancear colas automáticamente y ver métricas y fallos en un panel.



