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

[Guía práctica completa] Laravel Scheduler y procesamiento por lotes: ejecución periódica, agregación, notificaciones, integraciones externas, control de concurrencia, recuperación ante fallos y UI de operaciones accesible

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

[Guía práctica completa] Laravel Scheduler y procesamiento por lotes: ejecución periódica, agregación, notificaciones, integraciones externas, control de concurrencia, recuperación ante fallos y UI de operaciones accesible

Lo que aprenderás en este artículo (puntos clave)

  • Cómo usar Laravel Scheduler para gestionar de forma centralizada la ejecución periódica sin aumentar el número de entradas cron
  • Cómo diseñar procesos batch comunes como agregaciones diarias, notificaciones programadas, gestión de vencimientos, sincronización de datos y limpieza de logs
  • Patrones prácticos para evitar ejecuciones duplicadas y procesos omitidos usando withoutOverlapping, onOneServer, locks e idempotencia
  • Cómo gestionar fallos en lotes, reintentos, alertas, logs de auditoría y runbooks operativos
  • Cómo delegar procesamiento pesado desde Scheduler hacia Queue y distribuir la ejecución de forma segura
  • Cómo diseñar una UI de administración accesible para historial de ejecución, visualización de progreso y notificaciones de finalización
  • Cómo construir una plataforma duradera de procesamiento periódico que incluya testing y operaciones en producción

Lectores objetivo (¿quién se beneficia?)

  • Ingenieros Laravel de nivel principiante a intermedio: quienes han dispersado lógica en cron y quieren organizarla
  • Tech leads / personal de operaciones: quienes quieren operar de forma segura lotes diarios y sincronizaciones externas de una manera visible y comprensible
  • PMs / CS / personal de back-office: quienes quieren construir un sistema donde las notificaciones y los procesos relacionados con fechas límite se ejecuten de forma fiable
  • Personal de QA / accesibilidad: quienes quieren que los resultados de los lotes y las notificaciones de error sean comprensibles para todo el mundo en la UI

Nivel de accesibilidad: ★★★★★

Este artículo cubre de forma concreta cómo presentar resultados de ejecución, progreso, avisos de error y acciones de reintento usando role="status" / role="alert", estructura de encabezados, visualización de estados que no dependa solo del color y UIs de administración operables con teclado.


1. Introducción: en la ejecución periódica, “que funcione” no es suficiente

A medida que sigues desarrollando con Laravel, inevitablemente llega un momento en el que empiezas a acumular procesos que deben ejecutarse a una hora fija cada día. Por ejemplo: agregación de ventas, generación de facturas, limpieza de vencimientos, notificaciones a miembros, sincronización con servicios externos, refresco de caché y limpieza de logs. Al principio puedes escribirlos directamente en el cron del servidor y funcionarán, pero cuantos más añadas, más fácil será llegar a un estado en el que ya no sabes qué se está ejecutando, dónde y cuándo.

La ejecución periódica también tiene la dificultad particular de que los fallos son difíciles de notar de inmediato. A diferencia de una UI, donde los errores aparecen justo delante de ti, con los procesos batch muchas veces solo descubres después que “la agregación de ayer no se ejecutó”, “las notificaciones nunca se enviaron” o “el mismo proceso se ejecutó dos veces”. Por eso es más seguro diseñar el procesamiento por lotes para que incluya no solo registro del schedule, sino también control de concurrencia, monitorización, notificaciones y reintentos.

Laravel Scheduler facilita mucho toda esta gestión. En este artículo organizaré los fundamentos de la ejecución periódica en orden y luego entraré en los temas prácticos que importan en proyectos reales: concurrencia, lógica de reintento, observabilidad e incluso la UI administrativa para operaciones.


2. Lo primero que debes entender: Laravel Scheduler es un “organizador de cron”

Los servidores suelen tener un mecanismo integrado de ejecución periódica llamado cron. La idea de Laravel Scheduler es muy sencilla: mantener el cron del servidor en una sola entrada, y dejar que la aplicación Laravel decida qué debe ejecutarse en el momento actual.

En otras palabras, en lugar de registrar docenas de líneas en cron, centralizas reglas como “todos los días a las 2:00”, “cada minuto” o “cada lunes” dentro de Laravel, en routes/console.php o app/Console/Kernel.php. Esto mejora drásticamente las revisiones de código, la consistencia entre entornos, las pruebas y la monitorización.

Laravel Scheduler es especialmente adecuado para los siguientes casos:

  • Jobs periódicos como tareas diarias, horarias o semanales
  • Jobs que requieren condiciones de ejecución como “hoy se omite”
  • Jobs cuyos logs de ejecución y notificaciones quieres gestionar de forma consistente dentro de la app
  • Jobs que deberían combinarse con colas y workers en segundo plano para distribuir con seguridad el procesamiento pesado

Por otro lado, para cosas fuera del ámbito de la aplicación Laravel, como monitorización a nivel de sistema operativo o copias de seguridad de la base de datos, no es necesario forzarlo todo dentro de Scheduler. Mantenerlo dentro del área de responsabilidad de la aplicación es el enfoque más práctico.


3. Configuración básica: una entrada cron, definiciones de procesos centralizadas en Laravel

Primero, en el servidor de producción, registra una sola línea cron como esta:

* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1

Esa única línea actúa como el disparador que le pregunta a Laravel cada minuto: “¿Hay algo que deba ejecutarse en este momento?”.

Después, define los schedules reales en Laravel. En Laravel 11 y posteriores, escribirlos en routes/console.php suele ser la opción más limpia. Por ejemplo, para ejecutar la agregación diaria de ventas a las 2:00 AM:

use Illuminate\Support\Facades\Schedule;

Schedule::command('report:daily-sales')
    ->dailyAt('02:00');

Esta simplicidad es una de las mayores fortalezas de Scheduler. Con solo mirar el archivo, puedes ver enseguida qué se ejecuta y cuándo. En la revisión de código es fácil confirmar “este proceso corre todos los días a las 2:00”, y también mejora la reproducibilidad entre entornos.


4. Trabajos batch típicos: cuatro categorías que conviene organizar primero

Los procesos batch son muy variados, pero en la práctica se vuelven mucho más fáciles de gestionar si los divides en las siguientes cuatro categorías.

4.1 Agregación

  • Agregación diaria de ventas
  • Agregación del número de usuarios activos
  • Generación de informes mensuales
  • Refresco de tablas materializadas para dashboards

Suelen utilizarse para pantallas e informes. Un pequeño retraso quizá no sea fatal, pero que terminen de forma fiable todos los días es muy importante.

4.2 Notificaciones

  • Recordatorios de vencimiento
  • Notificaciones de emisión de facturas
  • Avisos de caducidad de contraseña
  • Notificaciones de finalización de exportación

Las notificaciones tienen un impacto muy visible en los usuarios, así que se necesita un cuidado especial para evitar omisiones y duplicados.

4.3 Sincronización / integración

  • Sincronización de datos desde APIs externas
  • Integraciones con herramientas de contabilidad / CRM / MA
  • Importación de información de stock o envíos

Estas tienden a fallar más por factores externos, así que la lógica de reintentos y la idempotencia son especialmente importantes.

4.4 Limpieza / mantenimiento

  • Eliminación de archivos temporales
  • Eliminación de logs antiguos
  • Limpieza de tokens vencidos
  • Actualizaciones de estado (publicación programada, gestión de vencimientos)

Este tipo de jobs “simplemente tienen que seguir ejecutándose en silencio”, lo que significa que los fallos son fáciles de pasar por alto. Precisamente por eso la monitorización y el historial de ejecución son tan importantes.


5. Commands y jobs: es más seguro no ejecutar procesamiento pesado directamente desde Scheduler

Un error común de principiantes es meter todo el procesamiento pesado directamente dentro del objetivo de Scheduler. Por supuesto, esto está bien para tareas pequeñas, pero a medida que crece el volumen de datos se convierte en una causa de timeouts y problemas de memoria. Por eso, el enfoque recomendado es: dejar que Scheduler solo dispare el trabajo, y delegar el procesamiento pesado real a un Job.

5.1 Crear un comando Artisan

php artisan make:command DailySalesReportCommand
namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Jobs\GenerateDailySalesReport;

class DailySalesReportCommand extends Command
{
    protected $signature = 'report:daily-sales';
    protected $description = 'Generate the daily sales report';

    public function handle(): int
    {
        GenerateDailySalesReport::dispatch(now()->subDay()->toDateString());

        $this->info('Dispatched the daily sales report generation job.');

        return self::SUCCESS;
    }
}

5.2 Dejar que el Job haga el procesamiento real

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\SerializesModels;

class GenerateDailySalesReport implements ShouldQueue
{
    use Dispatchable, Queueable, SerializesModels;

    public function __construct(public string $targetDate) {}

    public int $tries = 5;
    public int $timeout = 300;

    public function handle(): void
    {
        // Aggregation logic
    }
}

Con este diseño, Scheduler se mantiene ligero, mientras que los jobs pesados pueden monitorizarse mediante Queue / Horizon. Operativamente, también resulta mucho más fácil separar “el schedule sí se ejecutó” de “el job encolado falló”.


6. Control de concurrencia: withoutOverlapping es una de las primeras cosas que deberías aprender

Una de las cosas más peligrosas en el procesamiento periódico es que la siguiente ejecución empiece antes de que la anterior haya terminado. Por ejemplo, si una tarea de sincronización programada cada minuto tarda 90 segundos, la ejecución del siguiente minuto comenzará antes de que termine la anterior, a menos que hagas algo al respecto. Eso conduce a procesamiento duplicado y condiciones de carrera.

Laravel ofrece un método muy conveniente para esto:

Schedule::command('sync:external-orders')
    ->everyMinute()
    ->withoutOverlapping();

Esto evita la siguiente ejecución si la anterior sigue en marcha. Es extremadamente útil, pero no es magia. Conviene entender lo siguiente:

  • En algunos casos quizá quieras ajustar el tiempo de expiración del lock
  • Si el tiempo de procesamiento es extremadamente largo, programarlo cada minuto puede ser en sí mismo un diseño incorrecto
  • Los jobs pesados suelen ser más fáciles de razonar si se convierten en Jobs encolados en lugar de ejecutarse directamente en Scheduler

En resumen, withoutOverlapping es potente, pero debe usarse junto con una conciencia clara del tiempo de ejecución y una revisión de diseño.


7. En configuraciones multi-servidor, onOneServer es esencial

Si tu aplicación de producción se ejecuta en varios servidores de aplicación y cada servidor está ejecutando schedule:run, la misma tarea programada puede iniciarse una vez en cada servidor. Para evitarlo, usa onOneServer().

Schedule::command('billing:issue-invoices')
    ->dailyAt('01:00')
    ->onOneServer();

Esto garantiza que incluso en una configuración de múltiples servidores solo uno de ellos ejecute la tarea. Es especialmente importante para jobs como emisión de facturas o notificaciones masivas. Por supuesto, esto también asume que una infraestructura de caché / locking compartida como Redis está configurada correctamente, así que siempre debe comprobarse junto con la configuración del entorno.


8. Idempotencia: diseña para que los reintentos sean seguros

Una vez que usas Scheduler y Queue, “volver a intentarlo porque falló” se convierte en una acción operativa realista. Pero si el diseño es débil, los reintentos pueden hacer que se envíe el mismo email dos veces o que se emita la misma factura dos veces. Aquí es donde la idempotencia se vuelve crítica.

Por ejemplo, en la lógica de emisión de facturas, podrías pensar así:

  • Si ya existe una factura para el periodo objetivo, no crear otra
  • Usar un flag de emitido o una restricción única sobre el mes objetivo para evitar duplicados
  • Incluso si el proceso falla a mitad, un reintento no debería corromper la consistencia

Ejemplo:

$invoice = Invoice::firstOrCreate(
    [
        'customer_id' => $customer->id,
        'billing_month' => $billingMonth,
    ],
    [
        'status' => 'issued',
        'total_amount' => $amount,
    ]
);

Al asegurarte de que “la misma entrada produce el mismo resultado”, haces que tanto los reintentos de Scheduler como los de Queue sean mucho más seguros. En el procesamiento batch, esta forma de pensar es extremadamente importante.


9. Condiciones de ejecución flexibles: usa when y skip

Los jobs periódicos no siempre tienen que ejecutarse todas y cada una de las veces. Por ejemplo:

  • Ejecutar solo en días laborables
  • Ejecutar solo en producción
  • Ejecutar solo a fin de mes
  • Ejecutar solo cuando un feature flag esté activado

Laravel te permite expresarlo de forma natural con when() y skip().

Schedule::command('report:monthly')
    ->monthlyOn(1, '03:00')
    ->when(fn () => app()->environment('production'));
Schedule::command('notify:trial-expiring')
    ->dailyAt('10:00')
    ->skip(fn () => now()->isWeekend());

Esto evita que metas condicionales dentro del propio comando y hace que el comportamiento sea comprensible solo leyendo las definiciones de los schedules.


10. Notificaciones de resultados: haz que tanto el éxito como el fallo sean silenciosamente comprensibles

Los procesos batch son difíciles de ver, así que el diseño de notificaciones importa. Al mismo tiempo, si notificas en cada ejecución exitosa, enseguida se vuelve ruidoso. Una forma práctica de organizarlo es la siguiente:

  • Para jobs que se espera que tengan éxito todos los días
    • Los logs suelen ser suficientes
    • Notificar solo en caso de fallo
  • Para jobs en los que alguien está esperando activamente (exportaciones, informes mensuales)
    • Las notificaciones de inicio y finalización son útiles
  • Para jobs críticos (facturación, sincronización externa)
    • Monitorizar éxito / fallo y alertar ante fallo o incremento anormal

10.1 Ejemplo de visualización en la UI

@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

El punto clave aquí es: no usar solo color para indicar éxito o fallo. Textos como “Generación del informe iniciada” o “La sincronización falló. Vuelve a intentarlo.” deberían comunicar claramente el significado por sí mismos.


11. Historial de ejecución de lotes: visualizarlo en la UI administrativa estabiliza las operaciones

A medida que crece el número de jobs programados, la gente empieza a querer comprobar cosas como “¿La agregación de esta mañana terminó bien?” o “¿Cuántos registros se importaron en la sincronización de ayer?”. Una tabla de historial de ejecución dedicada se vuelve muy útil.

Campos de ejemplo para una tabla batch_runs:

  • name (nombre del job)
  • status (success / failed / running)
  • started_at
  • finished_at
  • message
  • meta (fecha objetivo, número de ítems, etc.)
  • trace_id

Crea un registro running cuando el comando o job empieza, y actualízalo en caso de éxito o fallo. Solo con esto ya es posible mostrar el “estado reciente de ejecución” en la UI administrativa, lo que hace mucho más fluidas las conversaciones con soporte y operaciones.

11.1 Ejemplo de UI de listado

  • Nombre del proceso
  • Fecha objetivo
  • Estado
  • Hora de inicio
  • Hora de fin
  • Cantidad
  • Enlace a detalles

Muestra los estados como texto, por ejemplo “Éxito”, “Falló” y “En ejecución”, usando el color solo como ayuda secundaria. El principio de accesibilidad es el mismo aquí que en cualquier otro lugar.


12. Accesibilidad en la UI administrativa: importa precisamente porque esta es una pantalla de operaciones

El personal de operaciones suele usar pantallas administrativas durante largos periodos de tiempo. Precisamente por eso tiene especial valor que estas pantallas sean “menos agotadoras” y “más difíciles de usar mal” incluso que las pantallas normales de usuario final. Para las pantallas de historial y reintento de lotes, en particular, merece la pena enfatizar los siguientes puntos:

  • Una estructura correcta de encabezados
  • Tablas construidas correctamente con <table>
  • Estado no expresado solo con color
  • Botones de reintento con significado claro
  • Notificaciones de ejecución / éxito / fallo usando role="status" / role="alert"
  • Operabilidad completa con teclado desde lista → detalle → reintento

Por ejemplo, en lugar de un botón de reintento que diga solo “Reintentar”, es más seguro mostrar algo como “Reintentar agregación de ventas de 2026-03-31”. Un diseño donde el significado sea claro solo con el texto, sin depender de lo visual, es lo ideal.


13. Flujo de recuperación ante fallos: incluso un Runbook breve ayuda

Cuando un proceso batch falla, es estresante tener que pensarlo todo desde cero cada vez. Por eso, incluso un Runbook corto es muy potente. Como mínimo, ayuda a definir lo siguiente:

  • Alcance del impacto (qué proceso, qué fecha objetivo, qué función)
  • Dónde revisar logs (trace_id, historial de lotes, logs del job)
  • Condiciones de reintento (¿se puede reintentar directamente o hay que corregir datos primero?)
  • Cómo comunicar el impacto al usuario (si es necesario, avisar a CS o a administradores)
  • Correcciones permanentes (fix de código, añadir monitorización, añadir tests)

Con el procesamiento batch, “que nunca falle” es menos importante que “cuando falle, podamos restaurarlo con calma”. Un Runbook también reduce mucho la carga psicológica.


14. Testing: protege más el job y la lógica de branching que Scheduler en sí

Como gran parte de Scheduler es funcionalidad del framework, a menudo es más efectivo centrarse en estos puntos que depender en exceso de pruebas end-to-end para todo:

  • El comando despacha correctamente el job
  • El Job se comporta de manera idempotente
  • El branching condicional (solo días laborables, solo fin de mes) funciona como se espera
  • En caso de fallo, se dejan historial y logs

14.1 Test de que el comando despacha el job

use Illuminate\Support\Facades\Queue;

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

    $this->artisan('report:daily-sales')
        ->assertExitCode(0);

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

14.2 Test de idempotencia

Un test como “aunque se ejecute dos veces para la misma fecha objetivo, el informe no se crea dos veces” es extremadamente valioso en proyectos reales.


15. Errores comunes y cómo evitarlos

  • Escribir procesamiento pesado directamente dentro de Scheduler
    • Cómo evitarlo: dejar que Scheduler solo dispare y mover el procesamiento real a Jobs
  • La ejecución duplicada provoca notificaciones o facturas duplicadas
    • Cómo evitarlo: withoutOverlapping, onOneServer, idempotencia
  • No hay visibilidad del éxito / fallo, así que las caídas pasan desapercibidas
    • Cómo evitarlo: historial de ejecución, Horizon, notificaciones de fallo, alertas
  • Nada se ejecuta porque cron no estaba configurado
    • Cómo evitarlo: checklist de despliegue y health checks
  • Las caídas de la API externa provocan fallos repetidos sin fin
    • Cómo evitarlo: límites de reintentos, backoff, aislamiento de fallos
  • La visualización de estado en la UI administrativa depende solo del color
    • Cómo evitarlo: incluir siempre etiquetas de texto
  • El botón de reintento es demasiado peligroso
    • Cómo evitarlo: mostrar claramente el alcance objetivo y añadir confirmación si hace falta

16. Checklist (para distribuir)

Diseño de Scheduler

  • [ ] cron está centralizado en una sola entrada schedule:run
  • [ ] los jobs programados pueden listarse del lado de Laravel
  • [ ] condiciones como “solo producción” están escritas explícitamente

Seguridad

  • [ ] withoutOverlapping se aplica donde hace falta
  • [ ] onOneServer se considera en configuraciones multi-nodo
  • [ ] existe idempotencia (prevención de creación duplicada, comprobaciones de estado de envío)

Asincronía

  • [ ] el procesamiento pesado se delega de Commands a Jobs
  • [ ] Queue / Horizon monitorizan retrasos y fallos
  • [ ] el número de reintentos, timeout y backoff están definidos explícitamente

Observabilidad

  • [ ] los resultados de ejecución pueden rastrearse en tablas de historial o logs
  • [ ] los jobs importantes registran trace IDs y fechas objetivo
  • [ ] existen notificaciones / alertas ante fallos

UI / accesibilidad

  • [ ] el estado de ejecución se muestra en texto
  • [ ] role="status" / role="alert" se usan apropiadamente
  • [ ] las acciones de reintento son operables con teclado
  • [ ] la visualización del estado no depende solo del color

Testing

  • [ ] existen tests para el despacho del comando
  • [ ] existen tests de idempotencia para los Jobs
  • [ ] existen tests para comprobar logs o historial en caso de fallo

17. Resumen

Laravel Scheduler es un mecanismo muy práctico para gestionar la ejecución periódica de una forma visible y organizada. Pero no basta con registrar schedules. El trabajo pesado debe delegarse en Queues, la ejecución duplicada debe evitarse con control de concurrencia, los reintentos deben hacerse seguros mediante idempotencia, y el historial de ejecución y las notificaciones deben permitir notar cuando algo ha dejado de funcionar. Además, hacer accesibles la UI de operaciones y el flujo de reintento reduce tanto la carga sobre los administradores como el riesgo de uso incorrecto. Empieza organizando solo una tarea periódica con el conjunto de cuatro piezas: Scheduler, Job, historial y notificación. A partir de ahí, podrás ampliarlo poco a poco hasta convertirlo en una base operativa silenciosamente sólida.


Enlaces de referencia

Salir de la versión móvil