Come scrivere codice PHP che dura: principi pratici di longevità del software

Come scrivere codice PHP che dura: principi pratici di longevità del software

Nei vent'anni in cui ho lavorato con codice PHP - il mio, quello dei colleghi, quello dei predecessori e quello ereditato da clienti in emergenza - ho visto codice di ogni qualità invecchiare in modi molto diversi. Ho visto applicazioni scritte nel 2012 con PHP 5.4 che nel 2025 sono ancora comprensibili, manutenibili e estensibili da sviluppatori che non hanno mai incontrato l'autore originale. E ho visto applicazioni scritte nel 2022 con PHP 8.1 e Laravel 9 che nel 2025 sono già diventate "quel progetto che nessuno osa toccare." La differenza non sta nella versione di PHP, non sta nel framework, e non sta nella complessità del dominio - sta in una manciata di principi di design che separano il codice che invecchia come il vino dal codice che invecchia come il latte.

Questi principi non sono accademici - sono il distillato di centinaia di sessioni di code review, di decine di subentri su progetti altrui, e della mia stessa esperienza di rileggere codice che ho scritto cinque anni fa e di capire (o non capire) cosa avevo in testa quando l'ho scritto. Non sono i principi SOLID riformulati per l'ennesima volta - sono le regole operative che nel mio lavoro quotidiano producono la differenza più grande tra codice che funziona oggi e domani e codice che funziona oggi e sarà un problema domani.

Il naming è il 60% della leggibilità a distanza di tempo

Il principio più impattante sulla longevità del codice non ha nulla a che fare con design pattern, architettura o framework - è il naming. Un metodo chiamato process() in una classe chiamata Handler dice zero su cosa fa il codice: devi leggere l'implementazione, i commenti (se esistono) e i chiamanti per capire il contesto. Un metodo chiamato calcolaImportoNettoConScontiCategoria() in una classe chiamata CalcolatoreScontiOrdine dice esattamente cosa fa senza aprire il file - e tra cinque anni, quando qualcuno cerca "dove viene calcolato lo sconto sugli ordini?", lo trova con un grep senza leggere mille righe di codice.

La regola che insegno ai team è: se il nome del metodo non descrive completamente ciò che fa, il nome è sbagliato. Un nome lungo e descrittivo è sempre meglio di un nome corto e ambiguo. getUsersWithOverduePayments() è meglio di getUsers(). sendOrderConfirmationEmail() è meglio di sendEmail(). validateItalianTaxCode() è meglio di validate(). Il costo di un nome lungo è qualche carattere in più da digitare (che l'autocompletamento dell'IDE elimina); il beneficio è che tra cinque anni qualcuno capisce il codice senza leggere l'implementazione.

Un pattern di naming particolarmente tossico che trovo nei progetti legacy è il naming per implementazione invece che per intenzione: metodi chiamati loopAndCheck(), queryAndReturn(), parseAndSave(). Questi nomi descrivono come il metodo funziona, non cosa produce. Quando l'implementazione cambia (il loop diventa una collection map, la query diventa una cache read), il nome diventa fuorviante - il nome dice una cosa e il codice ne fa un'altra. Il naming per intenzione - trovaOrdiniScaduti(), verificaDisponibilitaMagazzino(), generaReportMensile() - resta valido indipendentemente dall'implementazione sottostante.

Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nel refactoring di codice PHP legacy - e il naming è il primo intervento che faccio in ogni progetto di modernizzazione, perché migliora la leggibilità di tutta la codebase senza cambiare una riga di logica.

Isolare le dipendenze esterne dietro interfacce: la decisione che paga tra 3 anni

Il secondo principio riguarda il modo in cui il codice interagisce con i servizi esterni: database, API di terze parti, servizi di pagamento, email, storage. Il codice che chiama direttamente Stripe::charge() in 47 punti del progetto è codice che sarà impossibile migrare quando Stripe cambierà API (e lo farà) o quando il cliente vorrà passare a un altro gateway di pagamento. Il codice che chiama $this->paymentGateway->charge() dove paymentGateway è un'interfaccia con un'implementazione Stripe iniettata dal container è codice che si migra in un'ora: scrivi la nuova implementazione per il nuovo gateway, cambi il binding nel container, e i 47 punti di chiamata non vengono toccati.

Questo pattern - isolare le dipendenze esterne dietro interfacce - è il principio con il ROI più alto a lungo termine su qualsiasi codebase che vive più di due anni. Ogni servizio esterno che l'applicazione usa (gateway di pagamento, servizio di email, provider SMS, servizio di geolocalizzazione, API del gestionale, servizio di firma digitale) dovrebbe essere accessibile attraverso un'interfaccia PHP con un'implementazione concreta iniettata dal container di dependency injection. Il costo di questa astrazione è minimo al momento della scrittura (una interfaccia + un adapter = 30 minuti di lavoro aggiuntivo per servizio), e il beneficio emerge quando il servizio esterno cambia, viene sostituito, o deve essere mockato nei test.

Ho visto questo principio pagare dividendi enormi in almeno sei progetti: un'applicazione che è migrata da SendGrid a Amazon SES in 2 ore (senza toccare i controller), un e-commerce che è passato da Stripe a PayPal in 4 ore (senza toccare il checkout), e un gestionale che ha sostituito un'API SOAP con un'API REST senza modificare il codice di business. In tutti e sei i casi, l'astrazione era stata introdotta anni prima dal developer originale come "buona pratica" - senza sapere che avrebbe fatto risparmiare decine di ore di lavoro al momento della migrazione.

I test non sono documentazione del codice - sono documentazione del comportamento

Il terzo principio sfida una convinzione diffusa: "i test servono a verificare che il codice funzioni." In realtà, i test servono a documentare il comportamento atteso del codice. Un test chiamato test_sconto_20_percento_si_applica_su_ordini_sopra_500_euro() documenta una regola di business che nessun commento nel codice e nessun documento di specifica può comunicare con la stessa precisione e la stessa affidabilità. Il test è eseguibile - il che significa che se qualcuno modifica la logica degli sconti e il test fallisce, la documentazione "grida" che il comportamento è cambiato. Un commento nel codice o un documento di specifica non gridano - restano silenziosamente obsoleti mentre il codice diverge da ciò che descrivono.

Per le codebase PHP longeve, il valore dei test non è nel tasso di copertura percentuale (che è una vanity metric che non dice nulla sulla qualità dei test) ma nella copertura delle regole di business: ogni regola di business critica - ogni calcolo, ogni condizione, ogni transizione di stato - dovrebbe avere almeno un test che la documenta. Se la regola è "gli ordini superiori a 500 euro hanno uno sconto del 20%, tranne per i clienti del piano free che non hanno mai sconti," ci devono essere almeno tre test: ordine da 600 euro con sconto, ordine da 400 euro senza sconto, e ordine da 600 euro per cliente free senza sconto. Questi tre test documentano la regola in modo inequivocabile - e tra cinque anni, quando qualcuno chiede "perché il cliente X non ha lo sconto?", la risposta è nel test, non nella memoria di un developer che non lavora più in azienda. Ho approfondito le strategie per l'introduzione di test in codebase legacy nel mio articolo sui test automatici senza riscrittura.

Resistenza al cambiamento: il codice che accetta le modifiche senza rompersi

Il quarto principio è la resistenza al cambiamento - la proprietà del codice che permette di aggiungere una nuova feature senza dover modificare il codice esistente in 15 punti diversi. Il codice fragile è quello dove ogni modifica produce un effetto domino: aggiungere un nuovo stato dell'ordine richiede di aggiornare 8 switch in 5 file; aggiungere un nuovo tipo di notifica richiede di modificare il service di notifica, il controller, la vista e il job in coda; aggiungere un nuovo campo al form di registrazione richiede di toccare il controller, il request, il model, la migration, il seeder, il test e la documentazione API.

Il codice resistente al cambiamento usa composizione e polimorfismo: i nuovi stati dell'ordine vengono aggiunti come nuovi casi dell'enum PHP con metodi di dominio senza toccare i consumer; i nuovi tipi di notifica vengono aggiunti come nuove classi che implementano un'interfaccia senza toccare il dispatcher; i nuovi campi del form vengono aggiunti al FormRequest e al model senza toccare il controller perché il controller usa $request->validated() e mass assignment controllato.

Configurazione esplicita: niente magia, niente convenzioni nascoste

Il quinto principio è l'esplicitezza della configurazione: tutto ciò che determina il comportamento dell'applicazione deve essere dichiarato in modo chiaro e trovabile, non nascosto in convenzioni implicite che funzionano "per magia" finché non smettono di funzionare. Il framework Laravel, che uso quotidianamente e che considero eccellente, ha un trade-off intrinseco: le convenzioni implicite (auto-discovery dei package, route model binding automatico, cast impliciti) velocizzano lo sviluppo iniziale ma rendono il comportamento del sistema meno prevedibile per chi non conosce tutte le convenzioni. Quando un nuovo sviluppatore arriva sul progetto e si chiede "perché l'utente nella route /users/{user} viene automaticamente caricato dal database?", la risposta è nel route model binding implicito di Laravel - una feature che non è dichiarata in nessun file di configurazione del progetto, ma è una convenzione del framework.

Il codice longevo preferisce l'esplicitezza alla magia. Invece del route model binding implicito, dichiaro esplicitamente il binding nel RouteServiceProvider. Invece dell'auto-discovery dei service provider, li registro esplicitamente nel bootstrap/providers.php. Invece dei cast automatici, dichiaro i cast nel model con $casts. Non perché la magia non funzioni - funziona benissimo - ma perché tra cinque anni, quando il framework avrà cambiato le sue convenzioni (e lo farà, come ha fatto tra Laravel 8 e Laravel 11 con la struttura delle directory, i middleware globali e il routing), il codice esplicito continua a funzionare perché non dipende dalle convenzioni, mentre il codice implicito si rompe perché la convenzione è cambiata.

Questo principio si applica anche alla configurazione dell'ambiente: i parametri che determinano il comportamento dell'applicazione (dimensione del pool di connessioni, TTL della cache, limiti di rate limiting, soglie di alerting) devono vivere nel file .env con nomi espliciti e valori di default documentati - non hard-coded nel codice e non derivati da formule implicite che nessuno ricorda come funzionano. Un CACHE_TTL_CATALOGO=3600 nel .env è comprensibile e modificabile da chiunque; un Cache::remember('catalogo', 60 * 60, ...) nel controller richiede di leggere il codice per capire il TTL, e se devi cambiarlo devi modificare il codice e fare un deploy.

Il codice PHP che dura non è il codice scritto con le tecniche più moderne del momento - è il codice scritto con la consapevolezza che qualcun altro lo leggerà tra cinque anni. Nomi espliciti, dipendenze isolate, test come documentazione, e struttura che accetta il cambiamento. Sono principi vecchi quanto l'ingegneria del software e nuovi quanto il progetto che stai iniziando oggi. Se il tuo team scrive codice PHP e vuoi stabilire standard di qualità che producano codice longevo, contattami per una sessione di coaching: in una giornata analizziamo il codice esistente, identifichiamo i pattern che invecchieranno male, e definiamo le convenzioni di naming, testing e architettura che il team adotta dal giorno successivo - un investimento di una giornata che cambia la qualità di tutto il codice prodotto negli anni a venire. Perché il codice che scrivi oggi è il legacy di domani - e la differenza tra un legacy che è un asset e un legacy che è un debito sta nelle decisioni che prendi oggi, non nelle tecnologie che scegli. Ho applicato questi stessi principi nel mio lavoro di refactoring sistematico su codebase PHP legacy, dove il primo intervento è sempre stabilire le convenzioni che il codice futuro dovrà rispettare - perché modernizzare il codice vecchio senza cambiare il modo in cui si scrive il codice nuovo è come svuotare una vasca con il rubinetto aperto.

Ultima modifica: