PHP 8.3 match expression e named arguments: modernizzare codice legacy senza rischi
A settembre 2025 ho preso in carico un'applicazione PHP 7.4 per un cliente del settore distribuzione - un gestionale di logistica con 35.000 righe di codice, senza framework, senza test, e con una struttura procedurale piena di switch annidati e funzioni con 12-15 parametri posizionali. Il primo passo della modernizzazione è stato l'upgrade del runtime a PHP 8.3 - un aggiornamento che di per sé non cambia il codice ma abilita un set di feature del linguaggio che, adottate gradualmente, trasformano la leggibilità e la robustezza del codice senza richiedere una riscrittura. Le due feature con il rapporto costo/beneficio più alto nella mia esperienza sono la match expression (introdotta in PHP 8.0 e maturata nelle versioni successive) e i named arguments (PHP 8.0+). In tre settimane di refactoring distribuito, ho convertito 67 switch statement in match expression, eliminando 340 righe di codice e tre bug latenti di fall-through, e ho convertito 23 chiamate di funzione con parametri posizionali in named arguments, rendendo il codice auto-documentante dove prima richiedeva commenti per capire cosa significasse il settimo parametro true di una funzione.
La decisione di quali feature adottare e dove adottarle non è casuale - è guidata da un'analisi dei punti dove il codice legacy è più fragile e dove le feature moderne producono il miglioramento più visibile. Non tutte le feature nuove di PHP 8.3 meritano di essere adottate in un refactoring: alcune (come le typed class constants o il readonly amendment) sono utili solo in codice nuovo, non nella modernizzazione di codice esistente. L'obiettivo è identificare i pattern del codice legacy che sono intrinsecamente fragili - i switch senza default, le chiamate con parametri posizionali ambigui, i confronti con == su tipi misti - e sostituirli con costrutti che il linguaggio enforza come sicuri.
Match expression vs switch: dove la differenza è un bug in meno
La match expression è il sostituto moderno dello switch statement per i casi in cui lo switch viene usato per restituire un valore o per assegnare una variabile. La differenza fondamentale non è sintattica - è semantica: match usa il confronto strict (===) invece del loose (==) di switch, e match lancia un'eccezione UnhandledMatchError se nessun braccio corrisponde al valore, invece di continuare silenziosamente come switch senza default. Queste due differenze eliminano due intere categorie di bug che nel codice legacy sono endemiche.
Il primo tipo di bug è il confronto loose: switch ($status) con case 0: matcha sia il valore intero 0 sia la stringa vuota "" sia il booleano false, perché 0 == "" e 0 == false sono entrambi true in PHP con confronto loose. Nel gestionale del cliente, uno switch sullo stato dell'ordine (un intero da 0 a 6) matchava accidentalmente lo stato "bozza" (0) quando il campo nel database era NULL convertito a stringa vuota - un bug che esisteva da tre anni e che causava sporadicamente ordini in bozza visualizzati come "confermati" nel backoffice. La conversione a match ha risolto il bug silenziosamente, perché match con confronto strict non confonde 0 con "".
Il secondo tipo di bug è il fall-through: uno switch senza break dopo un case esegue anche tutti i case successivi - un comportamento intenzionale nel design del linguaggio ma una fonte costante di bug quando il break viene dimenticato. Nel gestionale, ho trovato tre switch con fall-through accidentale dove il break era stato commentato durante un debugging e mai ripristinato. La match expression non ha fall-through perché non ha il concetto di break - ogni braccio è un'espressione isolata che restituisce un valore.
Il refactoring di uno switch in match segue un pattern meccanico che può essere applicato in sicurezza anche senza test, a patto che lo switch sia usato per assegnare un valore o restituire un valore. Nel mio profilo professionale trovi l'esperienza che porto nel refactoring di codice PHP legacy - e la regola che applico è: se lo switch ha side effect (modifica lo stato, chiama metodi, scrive nel database), non convertirlo in match. La match expression è pura - restituisce un valore e basta.
Named arguments: quando il settimo parametro true diventa leggibile
Il secondo pattern di modernizzazione con il miglior rapporto costo/beneficio è la conversione dei parametri posizionali in named arguments. PHP 8.0 permette di specificare il nome dei parametri nella chiamata di funzione: htmlspecialchars(string: $input, flags: ENT_QUOTES, encoding: 'UTF-8') invece di htmlspecialchars($input, ENT_QUOTES, 'UTF-8'). La differenza è nella leggibilità - e la leggibilità è l'unica proprietà del codice che peggiora monotonicamente nel tempo se non viene attivamente mantenuta.
Il caso d'uso più forte per i named arguments nel codice legacy sono le funzioni con molti parametri opzionali. Nel gestionale, la funzione creaMovimentoMagazzino aveva 14 parametri:
// PRIMA: 14 parametri posizionali - impossibile capire senza la firma
creaMovimentoMagazzino(
$articoloId, $quantita, $magazzino, 'carico',
null, null, true, false, $lotto, null,
$dataMovimento, 'automatico', $userId, false
);
// DOPO: named arguments - il codice si auto-documenta
creaMovimentoMagazzino(
articoloId: $articoloId,
quantita: $quantita,
magazzino: $magazzino,
tipo: 'carico',
lotto: $lotto,
dataMovimento: $dataMovimento,
origine: 'automatico',
utenteId: $userId,
ignoraGiacenzaMinima: true,
registraLog: false,
);Con i named arguments, quei true e false misteriosi hanno un nome - e qualsiasi sviluppatore che legge il codice sa immediatamente cosa significa ignoraGiacenzaMinima: true senza dover aprire la definizione della funzione e contare i parametri fino al settimo. Inoltre, i parametri con valore di default (null nel codice originale per i parametri che non servivano) possono essere omessi - la chiamata diventa più corta e più chiara.
Un avvertimento importante: i named arguments creano un accoppiamento tra il nome del parametro nella definizione della funzione e il nome usato nella chiamata. Se qualcuno rinomina il parametro nella definizione senza aggiornare le chiamate, il codice lancia un TypeError. Nel codice procedurale senza framework questo è un rischio limitato (il refactoring è manuale comunque), ma in una codebase con dipendenze esterne (librerie Composer) è un vincolo da tenere presente: i named arguments sulle funzioni di librerie esterne sono sicuri solo se la libreria dichiara i nomi dei parametri come parte della sua API pubblica.
Il processo di refactoring sicuro: come convertire 67 switch senza rompere nulla
La conversione meccanica di uno switch in match è semplice - la sfida è farlo in sicurezza su un codebase senza test. Il processo che ho sviluppato in anni di refactoring legacy prevede tre passaggi per ogni switch da convertire.
Il primo passaggio è la classificazione: lo switch è puro (restituisce un valore, nessun side effect) o impuro (modifica variabili, chiama metodi con effetti collaterali, scrive nel database)? Solo gli switch puri sono candidati alla conversione in match. Nel gestionale da 35.000 righe, dei 94 switch totali trovati con grep, 67 erano puri (assegnavano un valore a una variabile basandosi su una condizione) e 27 erano impuri (eseguivano logica diversa per ogni caso). Ho convertito i 67 puri in match e ho lasciato i 27 impuri come switch - forzare un match su uno switch impuro richiede una ristrutturazione della logica che va oltre il refactoring meccanico e introduce rischio.
Il secondo passaggio è la verifica del default o dell'esaustività. Uno switch senza default che riceve un valore non previsto non fa nulla - prosegue silenziosamente. Una match senza braccio di default lancia un UnhandledMatchError. Per ogni switch convertito, devo decidere: aggiungere un braccio default alla match (che replica il comportamento silenzioso dello switch, perdendo il vantaggio della rilevazione degli errori), o omettere il default e accettare che valori non previsti causino un'eccezione (il che è il comportamento corretto nella maggior parte dei casi, perché un valore non previsto è un bug che deve essere segnalato). Nel gestionale, ho scelto la seconda opzione per 60 dei 67 match e ho aggiunto un default con logging esplicito per gli altri 7, dove un valore non previsto era possibile per motivi di business (ad esempio, nuovi stati aggiunti nel database prima che il codice fosse aggiornato).
Il terzo passaggio è il test manuale post-conversione. Per ogni switch convertito, eseguo almeno tre test: un input con il caso più comune (per verificare che il comportamento base non sia cambiato), un input con il caso edge (valore nullo, valore zero, stringa vuota - i casi dove il confronto loose di switch e il confronto strict di match producono risultati diversi), e un input con un valore non previsto (per verificare che il default o il UnhandledMatchError funzionino come atteso). Questo testing manuale su 67 conversioni richiede circa 3 ore - il tempo meglio investito di tutto il refactoring, perché è il passaggio che cattura i bug di conversione prima che arrivino in produzione.
La velocità media di conversione nel progetto è stata di 15-20 switch al giorno, con 3 bug trovati e corretti durante il testing (tutti legati al confronto loose/strict su valori che erano integer nel database ma string nel codice PHP). Il risultato netto: 340 righe di codice eliminate (il boilerplate di break, case, e parentesi), 3 bug corretti, e un codice che chiunque può leggere e capire senza dover tracciare mentalmente il flusso di un switch con 8 case e 3 break mancanti.
Readonly properties e union types: gli altri quick win della modernizzazione
Oltre a match e named arguments, due altre feature di PHP 8 hanno un impatto significativo sulla qualità del codice legacy: le readonly properties (PHP 8.1) e gli union types (PHP 8.0). Le readonly properties eliminano la necessità dei setter per le proprietà che non devono cambiare dopo la costruzione dell'oggetto - un pattern comune nei DTO, nei Value Object e nei comandi. Un readonly string $codiceArticolo nel costruttore garantisce che il valore non possa essere modificato dopo l'istanziazione - una garanzia che nel codice legacy era affidata alla disciplina dello sviluppatore (e che veniva regolarmente violata).
Gli union types permettono di dichiarare che un parametro o un return type accetta più tipi - string|int, Ordine|null, array|Collection. Nel codice legacy senza tipizzazione, i parametri accettano qualsiasi tipo silenziosamente e i bug di tipo vengono scoperti in produzione quando un metodo riceve un array invece di un oggetto e il codice va in fatal error. Gli union types spostano la rilevazione del bug dal runtime (produzione) al type checking (IDE e analisi statica) - un miglioramento che da solo giustifica l'aggiornamento a PHP 8 anche se non si adotta nessun'altra feature nuova.
Il processo di adozione di queste feature in un refactoring incrementale segue lo stesso principio che ho descritto per gli enum nel mio articolo su PHP 8 Enums per i domini di business: una modifica alla volta, test dopo ogni modifica, deploy incrementale. La tentazione di "modernizzare tutto in un colpo" è forte ma pericolosa - nel contesto del codice legacy senza test, ogni modifica è un rischio, e raggruppare 100 modifiche in un singolo commit è moltiplicare quel rischio per 100. Ho scritto un articolo specifico sulla strategia di refactoring per codice PHP legacy che copre il framework generale di modernizzazione, di cui le feature di PHP 8 sono un sottoinsieme importante ma non l'unico strumento. Se la tua applicazione gira ancora su PHP 7.4 e vuoi aggiornarla a PHP 8.3 sfruttando le nuove feature per migliorare la qualità del codice, contattami per pianificare la migrazione: in una giornata identifichiamo le incompatibilità, testiamo il runtime su PHP 8.3, e definiamo il piano di refactoring incrementale per le feature che producono il massimo beneficio con il minimo rischio.