Cronjob in Laravel: l'integrazione tra framework e server

La gestione di attività pianificate è una componente essenziale in quasi ogni applicazione web di una certa complessità. Inviare email riepilogative, generare report, pulire record obsoleti dal database, sincronizzare dati con servizi esterni: tutte queste operazioni devono avvenire in modo automatico, a intervalli regolari, senza alcun intervento umano. A livello di sistema operativo, lo strumento tradizionale per questo scopo è il cron, un demone presente su tutti i sistemi Unix-like che esegue comandi o script secondo una pianificazione definita tramite la crontab.

Laravel offre un proprio sistema di scheduling che si sovrappone al cron in modo elegante: invece di registrare decine di voci nella crontab del server, si definisce una sola voce cron che avvia il comando Artisan schedule:run, e da quel momento in poi tutta la logica di pianificazione viene gestita interamente a livello applicativo, nel codice PHP. Questo articolo esplora in profondità come funziona questo sistema, come si configura sul server e come si costruiscono task complessi in modo manutenibile.

Il meccanismo di base: un solo cronjob per governarli tutti

Il principio fondamentale dello scheduler di Laravel è semplice: il sistema operativo esegue ogni minuto un singolo comando Artisan, e Laravel si occupa internamente di decidere quali task pianificati debbano girare in quel momento. La voce da inserire nella crontab del server è la seguente:

# Esegui il task scheduler di Laravel ogni minuto
* * * * * cd /var/www/html/my-app && php artisan schedule:run >> /dev/null 2>&1

I cinque asterischi nella sintassi cron rappresentano rispettivamente: minuto, ora, giorno del mese, mese, giorno della settimana. Il valore * significa "ogni", quindi questa voce viene eseguita ogni minuto, ogni ora, ogni giorno. Il reindirizzamento dell'output verso /dev/null sopprime qualsiasi output a meno che non si voglia invece conservare un log.

Per modificare la crontab dell'utente corrente (di solito l'utente di sistema con cui gira il web server, ad esempio www-data) si usa il comando:

# Apri la crontab dell'utente corrente
crontab -e

# Apri la crontab di un utente specifico (richiede privilegi di root)
crontab -u www-data -e

Definire i task pianificati: il metodo schedule

A partire da Laravel 11, la registrazione dei task pianificati avviene nel file routes/console.php oppure direttamente in un ServiceProvider. Nelle versioni precedenti (fino a Laravel 10) la posizione canonica era il metodo schedule della classe App\Console\Kernel. Il concetto non cambia: si riceve un'istanza di Illuminate\Console\Scheduling\Schedule e si concatenano i metodi di pianificazione.

Esempio in routes/console.php (Laravel 11+):

<?php

use Illuminate\Support\Facades\Schedule;

// Esegui il comando Artisan ogni ora
Schedule::command('emails:send-digest')->hourly();

// Esegui una closure ogni giorno a mezzanotte
Schedule::call(function () {
    // Elimina i record obsoleti dalla tabella dei log
    \App\Models\ActivityLog::where('created_at', '<', now()->subDays(30))->delete();
})->daily();

// Esegui un job in coda ogni cinque minuti
Schedule::job(new \App\Jobs\SyncExternalData)->everyFiveMinutes();

Esempio nel Kernel (Laravel 10 e precedenti):

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    // Registra i task pianificati dell'applicazione
    protected function schedule(Schedule $schedule): void
    {
        $schedule->command('emails:send-digest')->hourly();

        $schedule->call(function () {
            // Elimina i record obsoleti dalla tabella dei log
            \App\Models\ActivityLog::where('created_at', '<', now()->subDays(30))->delete();
        })->daily();
    }

    // Registra i comandi Artisan personalizzati
    protected function commands(): void
    {
        $this->load(__DIR__.'/Commands');
        require base_path('routes/console.php');
    }
}

Creare un comando Artisan personalizzato

I task pianificati più strutturati si basano su comandi Artisan dedicati. Ogni comando estende Illuminate\Console\Command e implementa il metodo handle, che contiene la logica da eseguire. Per generare un comando si usa Artisan stesso:

# Genera lo scheletro di un nuovo comando Artisan
php artisan make:command SendWeeklyReport

Il file generato si trova in app/Console/Commands/SendWeeklyReport.php:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\User;
use App\Mail\WeeklyReportMail;
use Illuminate\Support\Facades\Mail;

class SendWeeklyReport extends Command
{
    // Firma del comando, usata per richiamarlo da Artisan
    protected $signature = 'reports:send-weekly {--dry-run : Simula l\'invio senza spedire email}';

    // Descrizione mostrata nell'elenco dei comandi Artisan
    protected $description = 'Invia il report settimanale a tutti gli utenti attivi';

    public function handle(): int
    {
        // Recupera tutti gli utenti con il flag active impostato a true
        $users = User::where('active', true)->get();

        if ($users->isEmpty()) {
            $this->info('Nessun utente attivo trovato.');
            return Command::SUCCESS;
        }

        $this->info("Elaborazione di {$users->count()} utenti...");

        $bar = $this->output->createProgressBar($users->count());
        $bar->start();

        foreach ($users as $user) {
            if (!$this->option('dry-run')) {
                // Spedisce il report settimanale all'utente corrente
                Mail::to($user->email)->send(new WeeklyReportMail($user));
            }
            $bar->advance();
        }

        $bar->finish();
        $this->newLine();
        $this->info('Report settimanale inviato con successo.');

        return Command::SUCCESS;
    }
}

Una volta creato, il comando può essere pianificato nello scheduler:

// Esegui il report settimanale ogni lunedi alle 8:00
Schedule::command('reports:send-weekly')->weeklyOn(1, '08:00');

Frequenze di esecuzione disponibili

Laravel mette a disposizione un'ampia gamma di metodi per definire la frequenza di esecuzione di un task. I principali sono i seguenti:

// Ogni minuto
Schedule::command('stats:collect')->everyMinute();

// Ogni N minuti (2, 3, 4, 5, 10, 15, 20, 30)
Schedule::command('cache:warm')->everyFiveMinutes();
Schedule::command('cache:warm')->everyFifteenMinutes();
Schedule::command('cache:warm')->everyThirtyMinutes();

// Ogni ora
Schedule::command('feeds:fetch')->hourly();

// Ogni ora a un minuto specifico (es. alle :15 di ogni ora)
Schedule::command('feeds:fetch')->hourlyAt(15);

// Ogni giorno
Schedule::command('reports:daily')->daily();

// Ogni giorno a un orario specifico
Schedule::command('reports:daily')->dailyAt('06:30');

// Due volte al giorno
Schedule::command('sync:run')->twiceDaily(8, 20);

// Ogni settimana
Schedule::command('newsletter:send')->weekly();

// Ogni settimana in un giorno e orario precisi (0 = domenica, 1 = lunedi...)
Schedule::command('newsletter:send')->weeklyOn(2, '09:00');

// Ogni mese
Schedule::command('invoices:generate')->monthly();

// Ogni mese in un giorno e orario precisi
Schedule::command('invoices:generate')->monthlyOn(1, '00:00');

// Espressione cron personalizzata (massima flessibilita)
Schedule::command('custom:task')->cron('0 */6 * * *');

Vincoli e condizioni di esecuzione

Lo scheduler consente di aggiungere vincoli che determinano se un task debba effettivamente girare in un dato momento, anche quando la frequenza temporale è soddisfatta.

// Esegui solo nei giorni feriali (lunedi-venerdi)
Schedule::command('reports:daily')->daily()->weekdays();

// Esegui solo nei fine settimana
Schedule::command('backups:weekend')->daily()->weekends();

// Esegui solo tra le 8:00 e le 17:00
Schedule::command('alerts:check')->everyFiveMinutes()->between('08:00', '17:00');

// Esegui solo se la condizione restituisce true
Schedule::command('maintenance:run')->daily()->when(function () {
    // Controlla se la modalita manutenzione e abilitata nel file di configurazione
    return config('app.maintenance_mode_enabled', false);
});

// Esegui solo se la condizione restituisce false (inverso di when)
Schedule::command('data:sync')->everyHour()->skip(function () {
    // Salta l'esecuzione durante il fine settimana
    return now()->isWeekend();
});

// Esegui solo in un ambiente specifico
Schedule::command('analytics:push')->daily()->environments(['production']);

Evitare esecuzioni sovrapposte con withoutOverlapping

Un problema comune negli scheduler è il task overlap: se un'esecuzione richiede più tempo del previsto, la successiva potrebbe partire prima che la prima sia terminata, causando race condition o duplicazione di operazioni. Laravel gestisce questo scenario con il metodo withoutOverlapping, che utilizza la cache per acquisire un lock atomico prima di avviare il task.

// Impedisce che piu istanze del comando girino contemporaneamente
// Il lock scade automaticamente dopo 10 minuti (valore di default)
Schedule::command('reports:generate')->everyFiveMinutes()->withoutOverlapping();

// Specifica un timeout personalizzato in minuti per il lock
Schedule::command('reports:generate')->everyFiveMinutes()->withoutOverlapping(30);

Affinché withoutOverlapping funzioni correttamente, il driver di cache configurato in config/cache.php deve supportare le operazioni atomiche di lock. I driver database, redis e memcached sono tutti compatibili. Il driver file è supportato ma meno affidabile in ambienti con filesystem condivisi.

Esecuzione in background con runInBackground

Per default, i task pianificati vengono eseguiti in sequenza all'interno dello stesso processo PHP avviato da schedule:run. Se si hanno più task che girano nello stesso minuto, ognuno deve attendere che il precedente finisca. Il metodo runInBackground avvia il task in un processo separato, consentendo l'esecuzione parallela.

// Esegui questi task in parallelo nello stesso minuto
Schedule::command('reports:generate')->everyMinute()->runInBackground();
Schedule::command('cache:rebuild')->everyMinute()->runInBackground();
Schedule::command('search:index')->everyMinute()->runInBackground();

runInBackground richiede che il sistema operativo supporti l'esecuzione asincrona di processi tramite shell. Funziona correttamente su Linux e macOS; su Windows potrebbe avere comportamenti diversi. Non è compatibile con i task definiti tramite closure (Schedule::call), che per loro natura devono girare nello stesso processo.

Output e logging dei task

Per default lo scheduler scarta l'output dei comandi eseguiti. È possibile reindirizzarlo verso un file di log o inviarlo via email al termine dell'esecuzione.

// Scrivi l'output su un file di log (sovrascrive ad ogni esecuzione)
Schedule::command('reports:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'));

// Appendi l'output al file di log (non sovrascrive)
Schedule::command('reports:generate')
    ->daily()
    ->appendOutputTo(storage_path('logs/reports.log'));

// Invia l'output via email al termine dell'esecuzione
Schedule::command('reports:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'))
    ->emailOutputTo('admin@example.com');

// Invia l'output via email solo in caso di errore (codice di uscita != 0)
Schedule::command('reports:generate')
    ->daily()
    ->emailOutputOnFailure('admin@example.com');

Hook di ciclo di vita: before, after, onSuccess, onFailure

Lo scheduler espone hook che permettono di eseguire codice prima e dopo l'esecuzione di un task, oppure in risposta al suo esito.

use Illuminate\Support\Facades\Log;

Schedule::command('data:import')
    ->daily()
    ->before(function () {
        // Registra l'inizio dell'importazione nel log applicativo
        Log::info('Importazione dati avviata alle ' . now()->toDateTimeString());
    })
    ->after(function () {
        // Registra il completamento dell'importazione nel log applicativo
        Log::info('Importazione dati completata alle ' . now()->toDateTimeString());
    })
    ->onSuccess(function () {
        // Notifica Slack che l'importazione e andata a buon fine
        \Illuminate\Support\Facades\Http::post(config('services.slack.webhook'), [
            'text' => 'Importazione dati completata con successo.'
        ]);
    })
    ->onFailure(function () {
        // Invia un'email di allarme agli amministratori in caso di fallimento
        \Illuminate\Support\Facades\Mail::to('admin@example.com')
            ->send(new \App\Mail\SchedulerFailureMail('data:import'));
    });

Ping a URL esterni: integrazione con sistemi di monitoraggio

Molti servizi di monitoraggio dei cronjob (come Healthchecks.io, Cronitor o Dead Man's Snitch) si aspettano un ping HTTP a un URL specifico prima e/o dopo l'esecuzione del task per verificare che sia avvenuto. Laravel integra questo pattern con i metodi pingBefore e thenPing.

// Esegui un ping prima e dopo il task per monitorare l'esecuzione
Schedule::command('backups:run')
    ->daily()
    ->pingBefore('https://hc-ping.com/your-uuid/start')
    ->thenPing('https://hc-ping.com/your-uuid');

// Versione condizionale: esegui il ping solo se la condizione e vera
Schedule::command('backups:run')
    ->daily()
    ->pingBeforeIf(app()->environment('production'), 'https://hc-ping.com/your-uuid/start')
    ->thenPingIf(app()->environment('production'), 'https://hc-ping.com/your-uuid');

Schedulare job in coda

Oltre ai comandi Artisan, lo scheduler può mettere in coda istanze di job Laravel. Questo è utile quando la logica del task è già incapsulata in un job riutilizzabile anche al di fuori della schedulazione.

<?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 App\Services\ExternalApiService;

class SyncProductCatalog implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // Numero massimo di tentativi in caso di fallimento
    public int $tries = 3;

    // Timeout in secondi prima che il job venga considerato fallito
    public int $timeout = 300;

    public function handle(ExternalApiService $apiService): void
    {
        // Recupera il catalogo aggiornato dall'API esterna e salva nel database
        $products = $apiService->fetchProductCatalog();

        foreach ($products as $productData) {
            \App\Models\Product::updateOrCreate(
                ['external_id' => $productData['id']],
                [
                    'name'  => $productData['name'],
                    'price' => $productData['price'],
                    'stock' => $productData['stock'],
                ]
            );
        }
    }
}
// Pianifica il job in coda ogni sei ore
Schedule::job(new \App\Jobs\SyncProductCatalog)
    ->everySixHours()
    ->onQueue('catalog')
    ->onConnection('redis');

Lo scheduler in ambiente Docker e containerizzato

In ambienti containerizzati con Docker, la gestione della crontab richiede attenzione particolare. Il container che esegue l'applicazione PHP generalmente non include un demone cron attivo. Esistono due approcci principali.

Il primo approccio consiste nel configurare la crontab direttamente all'interno del Dockerfile o dell'entrypoint del container:

# Installa il client cron nell'immagine
RUN apt-get update && apt-get install -y cron

# Copia il file di configurazione cron nel percorso corretto
COPY docker/crontab /etc/cron.d/laravel-scheduler

# Imposta i permessi corretti sul file crontab
RUN chmod 0644 /etc/cron.d/laravel-scheduler

# Applica la crontab per l'utente root
RUN crontab /etc/cron.d/laravel-scheduler

# Avvia il demone cron in primo piano assieme al web server
CMD cron && php-fpm

Il file docker/crontab:

# Esegui il task scheduler di Laravel ogni minuto come utente www-data
* * * * * www-data cd /var/www/html && php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1

Il secondo approccio, più pulito in un'architettura a microservizi, consiste nel dedicare un container separato esclusivamente allo scheduler, con un entrypoint che esegue in loop il comando schedule:run ogni minuto:

#!/bin/bash

# Entrypoint del container scheduler: esegue schedule:run ogni minuto in loop
while true; do
    php /var/www/html/artisan schedule:run --no-interaction
    sleep 60
done

Nel file docker-compose.yml:

services:
  app:
    build: .
    # ... configurazione del container principale

  scheduler:
    build: .
    # Sovrascrive il comando di default con l'entrypoint dello scheduler
    command: ["/usr/local/bin/scheduler-entrypoint.sh"]
    volumes:
      - .:/var/www/html
    depends_on:
      - app
      - db
      - redis
    environment:
      - APP_ENV=production

Testare i task pianificati

Laravel 11 ha introdotto un insieme di asserzioni per testare lo scheduler direttamente nelle test suite Pest o PHPUnit, senza dover eseguire realmente il task.

<?php

use Illuminate\Support\Facades\Schedule;

// Verifica che il comando sia registrato con la frequenza corretta
it('schedules the weekly report command on Monday at 8am', function () {
    $events = collect(app(Schedule::class)->events());

    // Cerca il task corrispondente al comando Artisan atteso
    $event = $events->first(function ($event) {
        return str_contains($event->command, 'reports:send-weekly');
    });

    expect($event)->not->toBeNull();
    expect($event->expression)->toBe('0 8 * * 1');
});

// Verifica che il task abbia withoutOverlapping abilitato
it('prevents overlapping for the data import command', function () {
    $events = collect(app(Schedule::class)->events());

    $event = $events->first(function ($event) {
        return str_contains($event->command, 'data:import');
    });

    expect($event)->not->toBeNull();
    expect($event->withoutOverlapping)->toBeTrue();
});

Per testare la logica interna di un comando Artisan in isolamento si usa invece $this->artisan in PHPUnit o il metodo equivalente in Pest:

<?php

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('sends weekly reports to all active users', function () {
    // Crea tre utenti attivi e uno inattivo nel database di test
    User::factory()->count(3)->create(['active' => true]);
    User::factory()->create(['active' => false]);

    \Illuminate\Support\Facades\Mail::fake();

    // Esegui il comando e verifica che termini con successo
    $this->artisan('reports:send-weekly')
        ->assertExitCode(0);

    // Verifica che siano state spedite esattamente tre email
    \Illuminate\Support\Facades\Mail::assertSentCount(3);
});

it('skips email sending in dry-run mode', function () {
    User::factory()->count(5)->create(['active' => true]);

    \Illuminate\Support\Facades\Mail::fake();

    // Esegui il comando in modalita simulazione senza spedire email
    $this->artisan('reports:send-weekly --dry-run')
        ->assertExitCode(0);

    \Illuminate\Support\Facades\Mail::assertNothingSent();
});

Comandi Artisan utili per lo scheduler

Laravel mette a disposizione alcuni comandi Artisan che facilitano il debug e la gestione dello scheduler in sviluppo e in produzione.

# Mostra l'elenco di tutti i task pianificati con frequenza e prossima esecuzione
php artisan schedule:list

# Esegui immediatamente tutti i task il cui momento e scaduto
php artisan schedule:run

# Avvia un worker interattivo che esegue i task in tempo reale (utile in sviluppo)
php artisan schedule:work

# Interrompi il worker avviato con schedule:work
php artisan schedule:interrupt

Il comando schedule:work è particolarmente utile in sviluppo locale perché elimina la necessità di configurare un vero demone cron sulla macchina dello sviluppatore: avviarlo in un terminale separato è sufficiente per simulare fedelmente il comportamento del sistema in produzione.

Considerazioni sulla manutenibilità in produzione

Man mano che il numero di task pianificati cresce, è opportuno adottare alcune pratiche per mantenere il codice organizzato. Una prima strategia consiste nel raggruppare i task per dominio applicativo in classi dedicate che implementano l'interfaccia Illuminate\Console\Scheduling\SchedulingMutex o, più semplicemente, in semplici classi invocabili registrate tramite closure nello scheduler.

<?php

namespace App\Console\Scheduling;

use Illuminate\Console\Scheduling\Schedule;

class ReportingSchedule
{
    // Registra tutti i task relativi ai report e alle statistiche
    public function register(Schedule $schedule): void
    {
        $schedule->command('reports:send-weekly')->weeklyOn(1, '08:00');
        $schedule->command('reports:send-monthly')->monthlyOn(1, '00:00');
        $schedule->command('stats:aggregate-daily')->dailyAt('23:50');
    }
}
<?php

namespace App\Console\Scheduling;

use Illuminate\Console\Scheduling\Schedule;

class MaintenanceSchedule
{
    // Registra tutti i task di manutenzione e pulizia del sistema
    public function register(Schedule $schedule): void
    {
        $schedule->command('model:prune')->daily();
        $schedule->command('cache:clear-expired')->hourly();
        $schedule->command('logs:rotate')->weeklyOn(0, '03:00');
    }
}

Queste classi possono poi essere richiamate centralmente:

// routes/console.php (Laravel 11+)

use Illuminate\Support\Facades\Schedule;
use App\Console\Scheduling\ReportingSchedule;
use App\Console\Scheduling\MaintenanceSchedule;

// Delega la registrazione dei task alle classi di dominio
app(ReportingSchedule::class)->register(app(\Illuminate\Console\Scheduling\Schedule::class));
app(MaintenanceSchedule::class)->register(app(\Illuminate\Console\Scheduling\Schedule::class));

Conclusioni

L'integrazione tra il sistema di scheduling di Laravel e il cron del server operativo è uno dei punti di forza del framework: con una sola riga nella crontab si ottiene il controllo completo su tutte le attività pianificate dell'applicazione, gestite nel codice PHP con tutta la potenza delle astraction di Laravel. I task possono essere comandi Artisan, closure, job in coda o callable arbitrari; possono avere frequenze complesse, vincoli contestuali, hook di ciclo di vita, protezione dall'overlap e integrazione con sistemi di monitoraggio esterni.

Comprendere come questo sistema funziona internamente — e come si configura correttamente sul server, in Docker o in ambienti CI/CD — è essenziale per costruire applicazioni affidabili che si mantengono autonomamente nel tempo.