Non riuscivamo più ad aggiornare Laravel e PHP. Alcune librerie abbandonate tenevano bloccate le dipendenze del gestionale e ogni modifica costava più di quanto avrebbe dovuto. Riscrivere tutto sembrava la strada più semplice: frontend nuovo, backend ripulito, versioni aggiornate. Almeno sulla carta.
Un gestionale usato per anni contiene molto più di quello che si vede nel repository. Ci sono eccezioni introdotte per clienti specifici e comportamenti mai documentati. Certi flussi funzionano in un determinato modo soltanto perché qualcuno, anni fa, ne aveva bisogno. Riscrivere significa ricostruire anche queste cose, spesso senza sapere in anticipo quali siano. E intanto la vecchia applicazione continua a ricevere modifiche.
In un progetto recente abbiamo riscritto buona parte di un gestionale, sia nel frontend sia nel backend. Ci abbiamo lavorato per mesi, senza sviluppare una seconda applicazione da mettere in produzione tutta insieme. Abbiamo sostituito il codice un pezzo alla volta, lasciando convivere vecchio e nuovo nello stesso progetto. In pratica, abbiamo applicato lo Strangler Fig Pattern dentro un monolite Laravel.
Il problema non era soltanto Blade
Il frontend usava viste Blade, jQuery e plugin che nel frattempo erano stati abbandonati. Ogni nuova schermata doveva fare i conti con quelle librerie. E finché fossero rimaste, aggiornare lo stack sarebbe stato impraticabile.
Sul backend i problemi erano altri. I controller erano diventati pesanti e la logica applicativa si trovava un po' nei controller, un po' nei servizi. Per capire cosa succedeva durante un'operazione bisognava spesso seguire il codice in più punti. Rifare le schermate lasciando tutto questo com'era avrebbe risolto solo una parte del problema.
Abbiamo introdotto Inertia e React per sostituire gradualmente le viste Blade. Nel frattempo, sul lato Laravel, abbiamo spostato la logica applicativa nelle Action: semplici classi PHP con un metodo execute(), senza librerie dedicate. Ai controller restavano la gestione della richiesta HTTP e la risposta. I servizi li abbiamo tenuti per le integrazioni con sistemi di terze parti.
Spostare la logica senza duplicarla
Volevamo che ogni operazione avesse un posto preciso nel codice. Spostare tutto in altre classi sarebbe servito a poco, se poi avessimo continuato a copiare la stessa logica.
Per esempio, un'Action di aggiornamento potrebbe aver bisogno di recuperare prima un record. Se esiste già un'Action che si occupa della lettura, quella di aggiornamento la riceve tramite dependency injection e ne chiama execute(). Il recupero rimane definito in un solo punto. Se quel comportamento cambia, lo modifichiamo lì, senza dover cercare tutte le operazioni che lo ripetono.
Le Action possono essere chiamate da controller diversi o da altre Action. Durante la migrazione, poter riutilizzare le stesse operazioni indipendentemente dalla schermata che le richiedeva ci ha semplificato parecchio il lavoro.
Due interfacce, le stesse operazioni
Una volta estratta la logica nelle Action, tenere in funzione le viste Blade è stato più semplice del previsto. Il controller invocava l'Action e poi restituiva una vista Blade oppure una pagina Inertia, a seconda della funzionalità. L'operazione eseguita era la stessa.
Con Inertia potevamo continuare a usare rotte e controller Laravel, senza costruire un'API separata per React. Questo ci permetteva di riorganizzare il backend lasciando in funzione le vecchie schermate e, quando arrivava il momento, sostituire la pagina senza duplicare le regole applicative.
Per il nuovo codice abbiamo adottato DTO tipizzati con Spatie Laravel Data, che ci permetteva anche di generare i tipi TypeScript da usare nelle viste React. I dati scambiati tra backend e frontend avevano così contratti dichiarati, invece di strutture da ricostruire ogni volta leggendo il codice.
La scelta meno intuitiva riguardava Blade. Quando cambiava il contratto di un'operazione, aggiornavamo anche il punto in cui veniva invocato il render della vista vecchia. Adattavamo lì il nuovo contratto ai dati che il template si aspettava. Nella maggior parte dei casi, ciò che passavamo a Blade rimaneva invariato e non serviva modificare la vista.
Era comunque lavoro su codice destinato a sparire. Ma ci evitava di mantenere due contratti applicativi per tutti i mesi della migrazione. La compatibilità restava vicino alla vista Blade e poteva essere rimossa insieme a lei, senza condizionare le nuove Action.
Una funzionalità alla volta, ma per intero
Le nuove funzionalità nascevano già con Inertia e React. Quelle esistenti le migravamo quando c'era comunque da lavorarci, cercando di scegliere un'unità abbastanza piccola da poter essere verificata e rilasciata da sola.
Non sempre quell'unità coincide con una pagina. Una schermata può dipendere da modali, filtri o script condivisi con altre parti dell'interfaccia. Se, per riscriverla in React, dobbiamo portarci dietro gli stessi plugin jQuery che vogliamo eliminare, abbiamo soltanto spostato il problema. Prima di intervenire bisogna capire quali comportamenti appartengono davvero alla funzionalità.
Per questo tenevamo accessibile la versione precedente. Aprire la schermata Blade accanto a quella React aiutava a controllare pulsanti, permessi e comportamenti poco evidenti. Non sostituiva i test, ma permetteva di accorgerci anche di dettagli che non avremmo pensato di verificare.
Per un periodo abbiamo mantenuto due modi di costruire l'interfaccia. Tenevamo quindi aggiornato un documento con le funzionalità già migrate, le convenzioni adottate e i problemi ancora aperti. Era un riferimento per chi lavorava al progetto, compresi gli agenti AI. Senza quello, ogni nuova schermata avrebbe rischiato di seguire regole leggermente diverse dalle precedenti.
La parte che si può fare soltanto alla fine
Per mesi sono rimasti due modi di renderizzare le pagine, mentre il backend si organizzava progressivamente attorno alle Action. Le vecchie schermate continuavano a funzionare, senza costringerci a conservare la vecchia struttura anche nel codice nuovo.
Migrare le funzionalità quando c'era occasione ci ha permesso di distribuire il lavoro. A un certo punto, però, abbiamo dovuto occuparci anche delle ultime schermate rimaste: finché fossero esistite, non avremmo potuto liberarci di tutte le dipendenze obsolete.
Quando anche l'ultima funzionalità è stata migrata, abbiamo eliminato le viste Blade e le librerie deprecate. Solo allora abbiamo potuto aggiornare Laravel, PHP e il resto delle dipendenze alle versioni più recenti. Prima, alcune librerie abbandonate rendevano l'aggiornamento impraticabile.
Non abbiamo evitato la riscrittura. Abbiamo evitato di doverla rilasciare tutta insieme. Nel frattempo, il gestionale ha continuato a funzionare.