Curso de Laravel

Tareas programadas en Laravel: automatizar con el scheduler

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Todo sistema termina necesitando cosas que ocurren solas: el recordatorio de la cita de mañana, el respaldo de la base de datos, el reporte de ventas de cada lunes.

Antes eso significaba llenar el crontab del servidor de líneas incomprensibles. Laravel lo resuelve de otra forma, y es mucho mejor.

¡Empecemos!

El truco: un solo cron

Toda la programación de tareas vive en tu código, y el servidor solo necesita una línea:

* * * * * cd /var/www/clinica && php artisan schedule:run >> /dev/null 2>&1

Eso llama a Laravel cada minuto. Laravel mira su propia lista y decide qué toca ejecutar ahora.

La ventaja es enorme: las tareas están en el repositorio, viajan con el proyecto, se revisan en el control de versiones y no dependen de que alguien se acuerde de configurar el servidor. Añadir una tarea nueva no requiere tocar el crontab nunca más.

Para editar el crontab:

crontab -e

Definir las tareas

En routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('recordatorios:enviar')->dailyAt('08:00');

Schedule::command('reportes:semanal')->weeklyOn(1, '07:00');

Schedule::command('backup:run')->dailyAt('02:00');

También se pueden programar trabajos de cola y funciones anónimas:

Schedule::job(new LimpiarArchivosTemporales)->daily();

Schedule::call(function () {
    Cita::where('fecha', '<', now())
        ->where('estado', 'programada')
        ->update(['estado' => 'no_asistio']);
})->dailyAt('23:00');

Prefiero comandos a funciones anónimas. Un comando se puede ejecutar a mano para probarlo, se puede leer aparte y no llena console.php. La función anónima está bien para algo de dos líneas.

Las frecuencias

->everyMinute();
->everyFiveMinutes();
->everyFifteenMinutes();
->everyThirtyMinutes();
->hourly();
->hourlyAt(15);              // al minuto 15 de cada hora
->daily();                   // a medianoche
->dailyAt('08:30');
->twiceDaily(9, 18);
->weekly();
->weeklyOn(1, '08:00');      // lunes
->monthly();
->monthlyOn(1, '00:00');     // día 1
->lastDayOfMonth('23:00');
->quarterly();
->yearly();

->cron('0 */4 * * *');       // si prefieres la sintaxis clásica

Y se pueden combinar con restricciones:

Schedule::command('recordatorios:enviar')
    ->dailyAt('08:00')
    ->weekdays()
    ->timezone('America/La_Paz');
->weekdays();
->weekends();
->mondays();
->sundays();
->between('8:00', '18:00');
->unlessBetween('22:00', '6:00');
->when(fn () => Config::get('sistema.recordatorios_activos'));
->skip(fn () => Feriado::esHoy());

Ese timezone() importa en Bolivia. Si el servidor está en UTC —y casi todos lo están— una tarea a las 08:00 se ejecuta a las 04:00 hora local. Se puede fijar para todo el proyecto en config/app.php:

'timezone' => 'America/La_Paz',

Ese skip() con feriados es un ejemplo real: no tiene sentido mandar recordatorios de citas un 6 de agosto.

Crear un comando propio

php artisan make:command EnviarRecordatorios
<?php

namespace App\Console\Commands;

use App\Jobs\EnviarRecordatorio;
use App\Models\Cita;
use Illuminate\Console\Command;

class EnviarRecordatorios extends Command
{
    protected $signature = 'recordatorios:enviar {--dias=1 : Días de anticipación}';

    protected $description = 'Envía recordatorios de las citas próximas';

    public function handle(): int
    {
        $fecha = now()->addDays((int) $this->option('dias'))->toDateString();

        $citas = Cita::whereDate('fecha', $fecha)
            ->where('estado', 'programada')
            ->whereNull('recordatorio_enviado_en')
            ->with('paciente')
            ->get();

        if ($citas->isEmpty()) {
            $this->info('No hay citas para recordar.');
            return self::SUCCESS;
        }

        $barra = $this->output->createProgressBar($citas->count());

        foreach ($citas as $cita) {
            EnviarRecordatorio::dispatch($cita);
            $cita->update(['recordatorio_enviado_en' => now()]);
            $barra->advance();
        }

        $barra->finish();
        $this->newLine();
        $this->info("Se encolaron {$citas->count()} recordatorios.");

        return self::SUCCESS;
    }
}

Cuatro cosas que hacen a un buen comando programado:

Devuelve un código de salida. self::SUCCESS o self::FAILURE. Es lo que permite detectar fallos desde fuera.

Encola en lugar de enviar. El comando marca el trabajo y sigue; el envío lo hacen los trabajadores de cola. Así, si un correo falla, no tumba todo el proceso.

Marca lo procesado. Ese recordatorio_enviado_en evita mandar el mismo aviso dos veces si el comando se ejecuta de nuevo. Un comando programado debería poder ejecutarse dos veces sin causar daño.

Informa. Los mensajes van al log y sirven para saber qué pasó.

Y ese comando se puede probar sin esperar a la hora:

php artisan recordatorios:enviar --dias=2

Entrada y salida en la consola

$this->info('Todo bien');
$this->error('Algo falló');
$this->warn('Cuidado');
$this->line('Texto plano');
$this->newLine();

$this->table(['Paciente', 'Fecha'], $filas);

$nombre = $this->ask('¿Nombre del paciente?');
$confirmado = $this->confirm('¿Continuar?');
$opcion = $this->choice('¿Qué reporte?', ['diario', 'mensual']);

Nada de ask() ni confirm() en comandos programados. Sin nadie delante, el comando se queda esperando para siempre. Si el comando puede correr programado y a mano, usa una opción --force.

Evitar solapamientos

Si una tarea tarda más que su intervalo, se acumulan ejecuciones encima:

Schedule::command('importar:catalogo')
    ->everyFiveMinutes()
    ->withoutOverlapping();

Con un límite de tiempo, por si el proceso se cae y deja el bloqueo puesto:

->withoutOverlapping(10);   // el bloqueo caduca a los 10 minutos

Esta es la protección que más falta hace en tareas frecuentes. Sin ella, un proceso lento de importación puede terminar con veinte copias corriendo a la vez.

Varios servidores

Si el sistema corre en más de un servidor, todos ejecutarían la misma tarea:

Schedule::command('reportes:mensual')
    ->monthlyOn(1, '06:00')
    ->onOneServer();

Requiere una caché compartida entre los servidores.

En segundo plano

Las tareas se ejecutan una detrás de otra. Si una tarda mucho, retrasa a las siguientes:

Schedule::command('backup:run')->dailyAt('02:00')->runInBackground();

Ver la salida

Schedule::command('recordatorios:enviar')
    ->dailyAt('08:00')
    ->appendOutputTo(storage_path('logs/recordatorios.log'));
->sendOutputTo($ruta);              // sobrescribe
->emailOutputTo('admin@clinica.com');
->emailOutputOnFailure('admin@clinica.com');

emailOutputOnFailure() es el que más uso. Avisa solo cuando algo sale mal, en lugar de mandar un correo diario que nadie lee.

Reaccionar al resultado

Schedule::command('backup:run')
    ->dailyAt('02:00')
    ->onSuccess(fn () => Log::info('Respaldo completado'))
    ->onFailure(fn () => Notification::route('mail', 'admin@clinica.com')
        ->notify(new RespaldoFallido));

Un respaldo que falla en silencio es peor que no tener respaldo, porque crees que estás cubierto. Esa notificación es lo mínimo.

Probar el programador

php artisan schedule:list

Muestra todas las tareas registradas y cuándo se ejecutará cada una la próxima vez. Es lo primero que reviso cuando una tarea no corre.

php artisan schedule:run     # ejecuta lo que toque ahora
php artisan schedule:work    # simula el cron en local

Ese schedule:work es muy cómodo en desarrollo: se queda corriendo y llama a schedule:run cada minuto, sin necesidad de configurar nada en el sistema.

Colas en hosting compartido

Aquí está el truco que prometí en la lección anterior.

En un hosting sin Supervisor no puedes mantener un queue:work vivo. La solución:

Schedule::command('queue:work --stop-when-empty --max-time=55')
    ->everyMinute()
    ->withoutOverlapping();

Cada minuto arranca un trabajador que vacía la cola y se apaga. El --max-time=55 evita que se solape con el siguiente.

No es lo ideal —los trabajos esperan hasta un minuto— pero para un sistema de gestión normal es más que suficiente, y funciona en cualquier hosting con cron.

Tareas que casi todo sistema necesita

// Limpiar trabajos fallidos antiguos
Schedule::command('queue:prune-failed --hours=168')->daily();

// Limpiar lotes viejos
Schedule::command('queue:prune-batches --hours=48')->daily();

// Borrar sesiones expiradas
Schedule::command('session:prune')->daily();

// Respaldo de la base de datos
Schedule::command('backup:run')->dailyAt('02:00');

// Recordatorios
Schedule::command('recordatorios:enviar')->dailyAt('08:00')->weekdays();

// Marcar como no asistidas las citas pasadas
Schedule::command('citas:cerrar-pendientes')->dailyAt('23:30');

Esa limpieza periódica evita que las tablas de trabajos fallidos y sesiones crezcan indefinidamente, que es un problema silencioso pero real.

Cuando la tarea no se ejecuta

La lista de revisión, en orden:

  1. ¿Está el cron puesto? crontab -l
  2. ¿La ruta es correcta? El cron no hereda tu entorno; usa rutas absolutas.
  3. ¿La zona horaria? php artisan schedule:list muestra la hora real.
  4. ¿Está en caché? Si cacheaste rutas o configuración, optimize:clear.
  5. ¿Hay algún error? Redirige la salida a un archivo en lugar de /dev/null mientras depuras.

Ese punto 2 es el más frecuente en hosting compartido: hay que usar la ruta completa al binario de PHP, que muchas veces no es simplemente php:

* * * * * /usr/local/bin/php /home/usuario/clinica/artisan schedule:run >> /dev/null 2>&1

Errores comunes

  • Poner cada tarea en el crontab en lugar de la única línea del programador.
  • Olvidar la zona horaria y ejecutar todo con cuatro horas de diferencia.
  • Tareas frecuentes sin withoutOverlapping().
  • Comandos que preguntan al usuario.
  • Comandos que no se pueden ejecutar dos veces sin duplicar datos.
  • Enviar correos directamente desde el comando en vez de encolarlos.
  • No enterarse de los fallos por mandar todo a /dev/null.
  • Rutas relativas en el cron.

Para cerrar

El programador de Laravel convierte las tareas automáticas en código versionado y legible, en lugar de líneas sueltas en un servidor que nadie recuerda haber puesto.

Lo esencial: una sola línea en el crontab, withoutOverlapping() en lo que corra seguido, y comandos que puedan ejecutarse dos veces sin hacer daño.

En la siguiente lección veremos eventos y listeners, que sirven para desacoplar lo que pasa después de una acción.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp