Subentro su Laravel 10 con Horizon fermo e scheduler silente: come ho recuperato un SaaS torinese di fleet management nei primi cinque giorni dopo la chiusura improvvisa del team di sviluppo esterno

Subentro su Laravel 10 con Horizon fermo e scheduler silente: come ho recuperato un SaaS torinese di fleet management nei primi cinque giorni dopo la chiusura improvvisa del team di sviluppo esterno

Perché un Laravel "che gira" può essere in realtà già morto dentro da tre settimane?

Il 3 giugno 2025 mi ha contattato il CTO (ex-sviluppatore senior, promosso sei mesi prima) di una società torinese che sviluppa e commercializza un SaaS B2B di fleet management per aziende di trasporto merci: 47 tenant attivi nel nord Italia, circa 13.000 autisti tra tutti i tenant, una media di 260.000 posizioni GPS raccolte ogni giorno e poi processate in batch per reportistica e fatturazione al tenant. L'applicazione era su un Hetzner AX52 nel datacenter di Falkenstein (Ryzen 7 7700, 64 GB RAM, RAID 1 NVMe) con un secondo nodo Hetzner Cloud CX42 dedicato a Redis e al worker Horizon, tutto orchestrato via un unico docker-compose in produzione - scelta discutibile ma funzionante. Lo stack applicativo era Laravel 10.48, PHP 8.2, MySQL 8.0, Redis 7, Laravel Horizon per la gestione delle queue, Spatie Multi-Tenancy per isolare i database dei singoli tenant.

Il team di sviluppo era un vendor esterno - una società di Bangalore con cinque sviluppatori dedicati al progetto - che a fine maggio 2025 aveva improvvisamente smesso di rispondere alle email. Il CTO aveva scoperto dopo quattro giorni di silenzio che la società stava attraversando una ristrutturazione interna e non avrebbe più lavorato su progetti esteri con contratti in corso, senza avvisare i clienti. La parte grave non era la chiusura del rapporto in sé - fastidiosa ma gestibile - ma la scoperta, avvenuta il 1° giugno quando i primi tenant hanno iniziato a lamentarsi, che il SaaS stava girando solo apparentemente: il frontend rispondeva normalmente, gli autisti potevano loggarsi e vedere i loro turni, ma nessuna notifica transazionale partiva più da settimane, nessun report giornaliero veniva generato, nessuna fattura mensile era stata emessa per il mese di maggio, e l'export contabile verso i gestionali dei tenant era fermo. Il sistema era clinicamente morto da circa tre settimane, ma nessuno se n'era accorto perché la parte visibile continuava a funzionare.

In cinque giorni ho riportato tutto a regime: audit completo del codice, identificazione del perché Horizon fosse fermo, riparazione dello stato Redis, ripristino del cron dello scheduler, rotazione dei segreti e documentazione operativa consegnata al CTO. Il costo in termini di ore dirette è stato contenuto, ma ciò che conta è che nessun tenant ha chiesto il recesso e le fatture di maggio sono state emesse con un ritardo di quindici giorni invece che di due mesi. In questo articolo racconto esattamente come, perché lo scenario "il SaaS sembra funzionare ma non sta facendo metà del suo lavoro" è molto più comune di quanto ci si immagini, e la diagnosi richiede un approccio specifico che non è quello dell'infrastruttura classica.

Stai cercando un Consulente Laravel esperto per gestire un subentro su un progetto abbandonato dal team originario? Nel mio profilo professionale trovi l'esperienza concreta in recupero di progetti Laravel in produzione, hardening e gestione di crisi tecniche in ambienti multi-tenant. Contattami per una consulenza diretta.

I primi trenta minuti: accertarsi che qualcosa stia girando davvero

La prima cosa che ho fatto, dopo essermi loggato come utente sudo sul server (il CTO aveva le credenziali, il team indiano non le aveva ruotate al momento della chiusura - un regalo non voluto), è stata non toccare niente. Letteralmente niente. L'istinto in questi casi è far partire php artisan horizon e sperare per il meglio, ma farlo significa perdere la possibilità di diagnosticare lo stato reale del sistema e capire cosa si è rotto quando. In Laravel 10 con Horizon e uno scheduler attivo, la prima batteria di comandi che lancio è questa, tutti in sola lettura:

cd /var/www/app
php artisan --version
php artisan about
php artisan horizon:status
php artisan queue:monitor default,notifications,reports,exports
php artisan schedule:list
php artisan schedule:test
grep -r "->cron\|->daily\|->hourly" app/Console/Kernel.php
ls -la storage/logs/ | tail -20
tail -200 storage/logs/laravel.log
tail -200 storage/logs/horizon.log

I risultati erano molto chiari e molto preoccupanti. horizon:status restituiva "inactive". schedule:list mostrava 34 task pianificati (generazione report giornalieri, batch di fatturazione, sync con GPS provider, cleanup sessioni, backup tenant) ma nessun task era stato eseguito dopo il 14 maggio. queue:monitor mostrava code con migliaia di job in waiting state e nessun worker attivo. L'ultimo log in laravel.log era datato 14 maggio 2025 alle 17:42 - e il file era piccolissimo, appena 2 MB. Questo è stato il primo segnale che mi ha fatto rizzare i capelli, perché un SaaS con 47 tenant attivi genera facilmente centinaia di MB di log al giorno in un'applicazione Laravel normale.

Il secondo passo è stato controllare il cron del sistema operativo, perché lo scheduler Laravel non può funzionare senza un * * * * * che chiami php artisan schedule:run ogni minuto. Il crontab -l dell'utente www-data era vuoto. Il crontab -l di root pure. Il /etc/cron.d/ conteneva solo file standard Debian. Qualcuno - o qualcosa - aveva disattivato il cron dello scheduler, ma non era chiaro quando né chi. Ho cercato nei file di sistema e ho trovato un file /etc/cron.d/laravel-schedule.disabled con timestamp 14 maggio 2025, ore 17:38 - esattamente quattro minuti prima dell'ultimo log di laravel.log. Qualcuno aveva rinominato quel file intenzionalmente, quel giorno, in quel preciso momento.

Ricostruire la sequenza: cosa è successo il 14 maggio alle 17:38?

A questo punto ero chiaramente in territorio forensics. Ho dedicato le ore successive a ricostruire la sequenza degli eventi con gli strumenti standard Linux: last per gli ultimi login, /var/log/auth.log per i tentativi SSH, /var/log/syslog per i comandi sudo eseguiti, storia bash di ogni utente con shell (cat /home/*/.bash_history 2>/dev/null), audit di /root/.bash_history. La ricostruzione è stata semplice perché il team indiano aveva un paio di utenti SSH shared (pessima pratica, ma questo è un altro discorso) e dalle bash history ho visto esattamente i comandi eseguiti il 14 maggio pomeriggio.

Quello che era successo era banale quanto catastrofico: uno degli sviluppatori del team indiano aveva eseguito un deploy di una modifica al sistema di generazione report. Durante il deploy, aveva sospeso scheduler e Horizon per evitare conflitti con la migrazione database in corso. Il deploy aveva fallito a metà, probabilmente per un problema di connessione o una disattenzione. Lo sviluppatore aveva lasciato tutto in stato sospeso con l'intenzione di riprendere il giorno dopo. Il giorno dopo, però, la sua società aveva ricevuto la comunicazione interna di ristrutturazione, e nessuno ha mai ripreso quel deploy. Lo scheduler Laravel è rimasto disattivato a livello di cron di sistema, Horizon è rimasto fermo, e il SaaS ha continuato a girare servendo il frontend ma senza processare nulla in background per tre settimane.

Questa ricostruzione non è accademica: è fondamentale perché determina cosa puoi riavviare in sicurezza e cosa invece è rischioso far ripartire. Se lo scheduler è fermo da tre settimane, riavviarlo significa far partire improvvisamente 34 task che devono recuperare tre settimane di lavoro - ci sono batch di fatturazione, di reportistica, di export contabile che potrebbero rieseguirsi e generare duplicati o corrompere stato. L'approccio sbagliato è "riavvio e vediamo cosa succede". L'approccio giusto è censire ogni task dello scheduler, decidere per ciascuno se vada fatto partire subito, posticipato, o saltato del tutto, e solo dopo procedere al ripristino ordinato.

Audit dei job schedulati: decidere cosa rieseguire e cosa saltare

Il mattino del 4 giugno ho passato tre ore sul Kernel.php dello scheduler per capire ogni singolo task. In un SaaS medio come questo ci sono quattro categorie di job schedulati, e per ognuna la decisione è diversa. I job idempotenti e temporali (tipo cleanup sessioni scadute, purge cache, sync configurazioni tenant) vanno fatti partire subito senza recupero: saltare tre settimane di cleanup non è un problema, ripartono puliti. I job transazionali con effetti esterni (tipo invio email di notifica, generazione fatture, push verso gestionali tenant) vanno analizzati uno per uno: alcuni vanno rieseguiti recuperando il backlog (le fatture di maggio vanno emesse), altri vanno saltati (le notifiche email vecchie di tre settimane non hanno senso recuperarle). I job di aggregazione dati (tipo report giornalieri, metriche dashboard) vanno decisi con il cliente perché toccano dati che l'utente finale vede. I job di integrazione con API esterne (sync GPS provider, webhook a terze parti) vanno coordinati con i provider esterni per evitare spike di richieste che li facciano sospendere.

Per la fatturazione ho concordato con il CTO di generare manualmente le fatture del mese di maggio con una variante del job di fatturazione che saltasse il controllo idempotente su billing_period_id (perché volevamo rieseguirlo forzando il periodo 2025-05). Per i report giornalieri abbiamo deciso di rigenerarne solo gli ultimi 7 giorni - quelli più probabilmente consultati dai tenant - e pubblicare un avviso nell'area cliente spiegando il disservizio. Per le email transazionali abbiamo saltato completamente tutto il backlog. Per la sync GPS abbiamo fatto un reset dello stato e lasciato che il giorno successivo ripartisse normalmente. Questa pianificazione è stata scritta in un foglio condiviso e approvata dal CTO prima di qualsiasi esecuzione.

Ripristino ordinato: cron, Horizon, supervisor, lock Redis

Il ripristino operativo è stato l'operazione meno interessante di tutto l'intervento, proprio perché ben pianificato. In ordine: prima ho ripristinato il cron dello scheduler con la riga standard Laravel (* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1), ma ho commentato tutti i task nello scheduler Laravel lasciando attivi solo quelli idempotenti e sicuri. Questo mi permetteva di avere lo scheduler "vivo" senza far partire nulla di pericoloso. Poi ho fatto partire Horizon, controllando lo stato delle code Redis. Horizon era gestito da Supervisor via un file di configurazione in /etc/supervisor/conf.d/horizon.conf che era ancora presente ma disabilitato (il team indiano aveva fatto supervisorctl stop horizon il 14 maggio e non l'aveva più riavviato). La configurazione era standard:

[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/horizon.log
stopwaitsecs=3600

Prima di riavviare Horizon ho fatto un audit manuale dello stato Redis per capire cosa ci fosse nelle code, usando redis-cli direttamente sul nodo CX42. Le code default e notifications avevano migliaia di job in attesa, la maggior parte dei quali erano email transazionali vecchie di settimane che il CTO aveva deciso di non rieseguire. Ho svuotato quelle due code con redis-cli FLUSHDB mirato (dopo aver fatto backup dello stato completo con redis-cli --rdb /root/redis-backup-pre-cleanup.rdb), ho lasciato intatte le code reports e exports perché contenevano job che volevamo invece rieseguire, e poi ho fatto ripartire Horizon con supervisorctl start horizon. Il dashboard Horizon su /horizon (protetto dal middleware Horizon::auth()) ha mostrato immediatamente i worker attivi e l'inizio del processamento del backlog residuo.

Lo scheduler Laravel l'ho riabilitato task per task nell'arco delle ore successive, controllando a ogni abilitazione che il task non generasse errori e che gli effetti fossero quelli attesi. Nel tardo pomeriggio del 4 giugno lo scheduler era completamente operativo, tutti i task critici erano ripartiti, e i primi tenant hanno iniziato a ricevere i report aggiornati dopo oltre tre settimane di buco. Questo approccio progressivo di riabilitazione è esattamente il motivo per cui in questo approfondimento sulla modernizzazione dei job in coda Laravel con queue fake e testing insisto sull'importanza del testing strutturato delle queue anche su progetti ereditati - senza una strategia di testing puoi solo sperare, e sperare non è una strategia.

Rotazione segreti e chiusura degli accessi al team precedente

Il 5 giugno è stato dedicato alla rotazione integrale dei segreti e all'inventario completo degli accessi. In un progetto Laravel multi-tenant come questo, i segreti sono parecchi e sparsi: APP_KEY (critica perché se la ruoti senza attenzione decifri male tutte le colonne encrypted esistenti, come le credenziali dei gestionali dei tenant), DB_PASSWORD del database principale e dei database per-tenant, REDIS_PASSWORD, chiavi API dei provider GPS esterni, chiavi API di Stripe per i pagamenti ricorrenti, chiavi API del provider email transazionale (Postmark nel loro caso), token delle webhook verso i gestionali tenant. L'APP_KEY non l'ho toccata perché Laravel la usa per cifrare dati a riposo nel database via il cast encrypted, e ruotarla richiede una procedura specifica di re-encryption di tutti i campi cifrati che va pianificata con attenzione (la farò come intervento separato, non in emergenza).

Tutti gli altri segreti li ho ruotati in sequenza, con rollout graduale per evitare di rompere connessioni attive: prima il nuovo segreto è aggiunto in parallelo al vecchio (quando il provider lo supporta), poi l'applicazione viene fatta puntare al nuovo, poi il vecchio viene revocato. Per MySQL e Redis, dove questo pattern non è direttamente supportato, ho fatto le rotazioni durante finestre di basso traffico notturno e ho verificato a ogni step che Horizon e scheduler continuassero a funzionare. Il file .env finale l'ho salvato in due copie: una sul server in /var/www/app/.env con permessi 640 www-data:www-data, e una cifrata con age nel password manager del CTO come backup. In parallelo, ho rimosso gli utenti SSH del team indiano (lastlog ha confermato che non si erano loggati da fine maggio), ruotato la password di root SSH anche se l'accesso root SSH era disabilitato per policy (sempre meglio), e disattivato tutti i token API personali dei loro sviluppatori su GitLab.

Documentazione operativa e fine dell'intervento

Il sesto e ultimo giorno - il 6 giugno - l'abbiamo dedicato alla documentazione operativa, perché un Laravel multi-tenant recuperato senza un runbook rimane una trappola per il futuro. Il runbook finale conteneva: architettura sintetica del SaaS (tenant, nodi Hetzner, servizi), inventario completo dei job schedulati con descrizione di cosa fanno e dipendenze, inventario delle code Horizon con priorità e worker count, procedura di deploy sicuro (con freeze dello scheduler durante le migration, come avrebbe dovuto fare il team indiano il 14 maggio ma senza lasciare le cose a metà), procedura di deploy Laravel automatizzato con Deployer che ho iniziato a impostare nei giorni successivi per eliminare la fragilità dei deploy manuali, procedura di gestione incidenti per il caso in cui Horizon si fermi di nuovo, contatti e accessi di emergenza.

Il CTO ha ricevuto anche una mappa del debito tecnico post-subentro da gestire nei 90 giorni successivi: quali sono i punti fragili del codice lasciato dal team precedente, quali refactoring sono prioritari, quali componenti rimpiazzare gradualmente (ad esempio il gestore di code personalizzato che il team indiano aveva affiancato a Horizon senza un motivo chiaro). Questo non era parte del recupero in emergenza ma dell'onboarding strategico per il nuovo ruolo di consulente tecnico che il CTO voleva assegnarmi nei mesi successivi. Per il tema più ampio del passaggio di consegne quando il team originario sparisce, ho scritto separatamente un approfondimento sul subentro senza sviluppatore manutentore su codice PHP legacy che copre anche scenari non Laravel.

Cosa salvare di questa esperienza per chi gestisce SaaS Laravel su server unmanaged

La lezione principale di questo subentro è che un SaaS Laravel "funzionante" dal punto di vista del frontend può essere in realtà profondamente rotto sul piano dei processi in background, e nessuno se ne accorge finché non arrivano le prime lamentele dei clienti finali. La soluzione non è un monitoring migliore - anche se un dashboard Horizon con notifiche Slack aiuta - ma una disciplina di deploy che non lasci mai stati intermedi senza un responsabile. Il 14 maggio alle 17:38 c'era uno sviluppatore in Bangalore che aveva lasciato lo scheduler sospeso con l'intenzione di riprendere il giorno dopo. Il giorno dopo quello sviluppatore non lavorava più su quel progetto, e nessuno si è chiesto "chi era incaricato di riavviare lo scheduler?". La mia raccomandazione universale in questi casi è impostare un piano di disaster recovery operativo che contempli esplicitamente lo scenario "il team di sviluppo smette di rispondere", con un runbook che qualsiasi persona tecnicamente competente possa eseguire partendo da zero informazioni.

Se gestisci un progetto Laravel in produzione su server Hetzner, OVH o Aruba, con Horizon, scheduler e code in background, e ti trovi in una situazione in cui il team originario è sparito oppure vuoi prevenire lo scenario costruendo subito le procedure giuste, contattami direttamente. Posso aiutarti a fare l'audit completo del progetto, identificare i punti fragili del ciclo di processing background, impostare monitoring e alerting su Horizon, e costruire un runbook operativo che permetta alla tua azienda di sopravvivere anche alla scomparsa improvvisa del team tecnico. Nel mio profilo professionale trovi i dettagli dell'esperienza concreta su SaaS Laravel multi-tenant e gestione di crisi in produzione.

Ultima modifica: