Scheduling Avanzado en Laravel: Tareas Programadas sin Complicaciones
Scheduling Avanzado en Laravel: Tareas Programadas sin Complicaciones
El scheduler de Laravel es una herramienta poderosa que permite automatizar tareas sin necesidad de configurar cron jobs manualmente. Sin embargo, muchos desarrolladores solo rascan la superficie, usando ->daily() o ->hourly() sin aprovechar todo su potencial. En este artículo exploraremos las funcionalidades avanzadas del scheduler que te ayudarán a construir sistemas de tareas más robustos, eficientes y fáciles de mantener.
¿Por qué va más allá del scheduling básico?
Cuando tus aplicaciones Laravel crecen, las tareas programadas también se vuelven más complejas. Necesitas:
- Evitar que tareas se solapen cuando toman demasiado tiempo
- Ejecutar subtareas condicionadas en cadena
- Sincronizar ejecuciones entre servidores
- Manejar fallos y reintentos automáticos
- Debuggear por qué un job no se ejecutó
El scheduler de Laravel tiene todas estas capacidades, pero necesitas conocerlas.
Estructura básica del Scheduler
Antes de entrar en lo avanzado, recordemos donde vive el scheduler:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('inspire')->hourly();
$schedule->call(function () {
Log::info('Task executed');
})->daily();
}
En producción, necesitas un único cron job que dispare el scheduler cada minuto:
* * * * * cd /path-to-laravel && php artisan schedule:run >> /dev/null 2>&1
Evitando solapamientos de tareas: WithoutOverlapping
Uno de los problemas más comunes es que una tarea tarde más de lo esperado y se solape con la siguiente ejecución. Imagina un comando que procesa 10,000 registros pero a veces tarda 2 horas:
// SIN PROTECCIÓN - Problema real
$schedule->command('process:orders')
->everyMinute();
// Si el comando tarda 15 minutos, se ejecutará 15 instancias simultáneamente
// Esto causa bloqueos, uso excesivo de memoria y datos corruptos
La solución es withoutOverlapping():
$schedule->command('process:orders')
->everyMinute()
->withoutOverlapping();
Esto usa un lock en el filesystem (o base de datos) para garantizar que solo una instancia se ejecuta. Puedes personalizar el timeout del lock:
$schedule->command('process:orders')
->everyMinute()
->withoutOverlapping(minutes: 30);
Si después de 30 minutos el lock sigue activo, se ignorará y se ejecutará de nuevo (útil si el proceso anterior falló).
Sincronización entre múltiples servidores
Si ejecutas Laravel en varios servidores, cada uno tiene su propio cron. Para evitar duplicación:
$schedule->command('backup:database')
->daily()
->withoutOverlapping()
->onOneServer();
onOneServer() usa Redis o la base de datos para coordinar que solo un servidor ejecute la tarea. Requiere configuración previa:
// config/cache.php - usar Redis como store
'default' => env('CACHE_DRIVER', 'redis'),
// O en el .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
Chaining de tareas con entonces/then
Laravel 13 mejoró significativamente las tareas encadenadas. Puedes ejecutar una tarea y luego otra si la primera tiene éxito:
$schedule->command('reports:generate')
->daily()
->then(function () {
Log::info('Report generated successfully');
// Aquí puedes enviar emails, notificaciones, etc.
});
Para cadenas más complejas:
$schedule->command('data:sync')
->hourly()
->then(function () {
\App\Models\SyncLog::create([
'status' => 'completed',
'timestamp' => now(),
]);
})
->thenWithOutput(function ($output) {
// Capturar la salida del comando
Log::info('Sync output: ' . $output);
});
Ejecutar múltiples comandos en secuencia
Si necesitas ejecutar varios comandos uno tras otro:
$schedule->call(function () {
Artisan::call('command:one');
Artisan::call('command:two');
Artisan::call('command:three');
})->daily();
O mejor aún, crea un comando específico que orqueste:
// app/Console/Commands/DailyPipeline.php
class DailyPipeline extends Command
{
protected $signature = 'pipeline:daily';
public function handle()
{
$this->call('command:one');
$this->info('Command one completed');
$this->call('command:two');
$this->info('Command two completed');
$this->call('command:three');
$this->info('Command three completed');
}
}
Luego en el scheduler:
$schedule->command('pipeline:daily')->daily();
Callbacks condicionales: only(), skip() y when()
A veces necesitas que una tarea se ejecute solo bajo ciertas condiciones:
// Ejecutar solo en producción
$schedule->command('emails:send')
->daily()
->onlyInProduction();
// Ejecutar solo en desarrollo
$schedule->command('debug:tasks')
->everyMinute()
->onlyInDebugMode();
// Condicional personalizado
$schedule->command('process:heavy')
->daily()
->when(function () {
return Cache::get('heavy_processing_enabled', false);
});
// Lo opuesto: skip() para omitir bajo ciertas condiciones
$schedule->command('maintenance:cleanup')
->daily()
->skip(function () {
return MaintenanceMode::isEnabled();
});
Caso de uso real: ejecutar un comando costoso solo si ciertos criterios se cumplen:
$schedule->call(function () {
$pendingOrders = Order::where('status', 'pending')
->where('created_at', '<', now()->subHours(24))
->count();
if ($pendingOrders > 100) {
Artisan::call('orders:process-old');
}
})->everyTenMinutes();
Manejo de Fallos y Reintentos
Laravel 13 introdujo el atributo #[Delay] para controlar reintentos de tareas en cola. Para el scheduler, puedes usar:
$schedule->command('data:validate')
->daily()
->onFailure(function () {
Log::error('Data validation failed!');
// Enviar notificación
Notification::send(
User::admins(),
new TaskFailedNotification('data:validate')
);
})
->onSuccess(function () {
Log::info('Data validation completed');
});
Reintentar automáticamente
Para reintentar si falla:
$schedule->call(function () {
try {
Artisan::call('risky:command');
} catch (Exception $e) {
Log::error('Command failed: ' . $e->getMessage());
// Reintentar después de 5 minutos
dispatch(new RetryCommand())
->delay(now()->addMinutes(5));
}
})->daily();
Timing Avanzado: Más allá de daily() y hourly()
El scheduler permite especificar tiempos muy precisos:
// Cada 5 minutos
$schedule->command('process:queue')
->everyFiveMinutes();
// Cada 10 minutos
$schedule->command('sync:data')
->everyTenMinutes();
// Cada 15 minutos
$schedule->command('update:cache')
->everyFifteenMinutes();
// Cada 30 minutos
$schedule->command('health:check')
->everyThirtyMinutes();
// Expresión cron personalizada
$schedule->command('custom:task')
->cron('0 0 1 * *'); // Primer día de cada mes a las 00:00
// Entre horas específicas
$schedule->command('process:emails')
->everyMinute()
->between('09:00', '17:00'); // Solo durante horario laboral
// Excepto entre horas
$schedule->command('system:backup')
->daily()
->unlessBetween('12:00', '14:00'); // No durante la comida
Expresiones cron comunes
// Cada lunes a las 9 AM
$schedule->command('weekly:report')
->weeklyOn(1, '9:00');
// Cada primer lunes del mes
$schedule->command('monthly:meeting')
->cron('0 9 * * 1 && day(1)'); // No funciona exacto, usa esto:
->monthlyOn(1, '09:00')
->dayOfWeek(1);
// Cada 6 horas
$schedule->command('frequent:task')
->cron('0 */6 * * *');
// Cada 15 minutos
$schedule->command('very:frequent')
->cron('*/15 * * * *');
Debugging del Scheduler
¿Por qué tu tarea no se ejecuta? Aquí hay varias estrategias:
1. Verificar que el cron está corriendo
# Comprobar si hay un cron en el servidor
crontab -l
# Si no ves nada, instalar:
crontab -e
# Agregar:
* * * * * cd /path-to-laravel && php artisan schedule:run >> /dev/null 2>&1
2. Registrar toda actividad del scheduler
// app/Console/Kernel.php
protected function scheduleCache()
{
return $this->cache
->remember(
'framework.schedule',
now()->addMinutes(5),
function () {
return collect($this->schedule(new Schedule))
->sortBy->nextRunTime
->values()
->all();
}
);
}
// En tu kernel, agrega logging:
protected function schedule(Schedule $schedule)
{
$schedule->command('process:orders')
->everyMinute()
->onSuccess(function () {
Log::info('Orders processed successfully', [
'time' => now(),
]);
})
->onFailure(function () {
Log::error('Orders processing failed');
});
}
3. Comando artisan para listar tareas programadas
php artisan schedule:list
Esto muestra todas las tareas configuradas y cuándo se ejecutarán a continuación.
4. Ejecutar manualmente una tarea
# Ejecutar una tarea específica
php artisan process:orders
# O forzar la ejecución del scheduler completo
php artisan schedule:run --verbose
5. Crear un comando de debugging personalizado
// app/Console/Commands/DebugSchedule.php
class DebugSchedule extends Command
{
protected $signature = 'schedule:debug';
protected $description = 'Debug scheduled tasks';
public function handle()
{
$schedule = app(Schedule::class);
// Aquí Laravel no expone directamente los eventos,
// pero puedes inspeccionar el log:
$this->info('Checking schedule logs...');
$logs = File::get(storage_path('logs/laravel.log'));
$relevant = collect(explode("\n", $logs))
->filter(fn($line) => str_contains($line, 'schedule'))
->reverse()
->take(10);
foreach ($relevant as $line) {
$this->line($line);
}
}
}
Ejemplo Real: Sistema de Sincronización Completo
Aquí está un caso de uso que combina muchas de las técnicas anteriores:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Sincronización principal cada 5 minutos
// Solo en un servidor, sin solapamientos
$schedule->call(function () {
$syncer = new DataSyncer();
$syncer->sync();
})
->everyFiveMinutes()
->withoutOverlapping(minutes: 10)
->onOneServer()
->when(function () {
// Solo si la sincronización está habilitada
return Setting::get('sync_enabled', true);
})
->onSuccess(function () {
// Notificar éxito
Log::info('Data sync completed', [
'timestamp' => now(),
'synced_records' => Cache::get('sync_count', 0),
]);
})
->onFailure(function () {
// Alertar sobre fallos
Log::error('Data sync failed');
Notification::send(
User::role('admin'),
new SyncFailureNotification()
);
});
// Backup diario a las 2 AM
$schedule->command('backup:run')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer()
->then(function () {
Log::info('Backup completed');
Cache::put('last_backup', now(), now()->addDays(1));
});
// Limpieza de datos antiguos, evitar horas pico
$schedule->command('cleanup:old-logs')
->daily()
->at('03:30')
->skip(function () {
return MaintenanceMode::isEnabled();
});
// Reportes generados cada lunes a las 8 AM
$schedule->command('reports:generate')
->weeklyOn(1, '08:00')
->then(function () {
Mail::send(new WeeklyReportMail(User::admins()));
});
}
Monitoreo y Alertas
Para sitios en producción, es crítico monitorear el scheduler:
// app/Console/Commands/MonitorSchedule.php
class MonitorSchedule extends Command
{
protected $signature = 'schedule:monitor';
public function handle()
{
// Verificar que al menos una tarea se ejecutó en la última hora
$lastExecution = Cache::get('last_schedule_run');
if (!$lastExecution ||
$lastExecution->diffInMinutes(now()) > 65) {
Alert::critical(
'Scheduler not running',
'No scheduled tasks executed in the last hour'
);
}
$this->info('Scheduler is running normally');
}
}
// Ejecutar este monitor cada minuto
$schedule->command('schedule:monitor')
->everyMinute();
Puntos clave
- WithoutOverlapping: Previene que tareas se solapen usando locks de filesystem o Redis
- OnOneServer: Sincroniza ejecuciones entre múltiples servidores