Migrare un gestionale PHP 5.6 a PHP 8.4 senza riscriverlo: il caso reale di un e-commerce B2B con 12 anni di codice procedurale
Qualche mese fa mi ha contattato il titolare di un piccolo e-commerce B2B del settore della componentistica industriale, con una clientela di alcune centinaia di aziende manifatturiere del Nord Italia. Il gestionale era un'applicazione PHP custom costruita una dozzina di anni fa da uno sviluppatore freelance che aveva lasciato il progetto diversi anni dopo: 47.000 righe di codice procedurale puro distribuito su 380 file, zero classi, zero namespace, zero Composer, 340 chiamate dirette a mysql_connect() e mysql_query(), template HTML mischiato alla logica con echo e concatenazione di stringhe. L'applicazione girava su un hosting condiviso con PHP 5.6 - sì, nel 2026 - e il provider aveva inviato una comunicazione formale: "PHP 5.6 verrà rimosso tra sessanta giorni. Aggiornate le vostre applicazioni."
Il titolare aveva sessanta giorni. L'applicazione gestiva il catalogo prodotti (alcune migliaia di SKU), il carrello B2B con listini personalizzati per cliente, la generazione di offerte PDF, la sincronizzazione notturna degli ordini con il gestionale ERP del magazzino via file CSV, e l'area riservata per i clienti con storico ordini e tracking delle spedizioni. Fatturato transitato: alcuni milioni di euro l'anno. Non era un sito vetrina - era il canale commerciale primario.
La tentazione del titolare era "facciamo un sito nuovo su Shopify". Gli ho fatto due conti: una migrazione del catalogo con migliaia di SKU, listini personalizzati per centinaia di clienti, logica di offerte PDF, integrazione con l'ERP - su Shopify o qualsiasi altra piattaforma - avrebbe richiesto 4-6 mesi e un budget che partiva da 40.000 euro, senza garanzia di parità funzionale nei primi mesi. La migrazione del codice esistente da PHP 5.6 a PHP 8.4, con il metodo che applico da dieci anni su progetti simili, richiedeva 4 settimane e un quinto del budget. L'applicazione non era bella, ma funzionava. Il mio lavoro non era renderla bella - era renderla sicura, supportata e manutenibile per i prossimi anni.
Perché un'applicazione PHP 5.6 in produzione nel 2026 è un'emergenza di sicurezza, non solo un debito tecnico?
PHP 5.6 ha raggiunto la fine del supporto il 31 dicembre 2018. Significa sette anni e due mesi senza nessuna patch di sicurezza. Ogni CVE scoperta da allora nelle funzioni core di PHP - e ce ne sono state decine - è una vulnerabilità aperta e documentata pubblicamente che un attaccante può sfruttare. Non è teoria: la pagina ufficiale dei CVE di PHP elenca le vulnerabilità per versione, e PHP 5.6 non riceve fix da nessuna di esse.
Ma il rischio non è solo nelle CVE del runtime. È nell'intero ecosistema: le librerie non supportano più PHP 5.6, i tool di sicurezza non lo analizzano, i provider di hosting lo rimuovono, i penetration tester lo segnalano come finding critico in ogni audit. Un'applicazione PHP 5.6 che gestisce dati di alcune centinaia di aziende B2B - anagrafiche, ordini, coordinate bancarie per i bonifici, indirizzi di spedizione - è un rischio GDPR concreto. L'Articolo 32 del GDPR richiede "misure tecniche adeguate" - e un runtime senza supporto di sicurezza da sette anni non è una misura adeguata per nessun DPO ragionevole.
Una precisazione sulla versione di destinazione, perché conta. Ho scelto PHP 8.4 e non l'ultima 8.5 (current stable rilasciata a novembre 2025) per una ragione pragmatica: nel momento della migrazione, 8.4 era active support con fine attesa il 31 dicembre 2026 e supporto di sicurezza fino al 2028, quindi il massimo della compatibilità di librerie ed estensioni testate in produzione. La regola che applico è sempre la stessa: per un'applicazione legacy si mira alla versione attiva più matura, non alla più recente in assoluto. La roadmap ufficiale, con le date di fine supporto per ogni branch, è sulla pagina delle versioni supportate di PHP, ed è da lì - non dalla memoria - che va deciso il target di una migrazione.
Nel mio profilo professionale trovi l'esperienza concreta su migrazioni PHP legacy per PMI italiane, dalla versione 5.4 fino alla 8.x, con gestione del rischio in produzione e zero downtime.
Le breaking changes che ho trovato nel codice del cliente legacy
Prima di toccare qualsiasi cosa, ho passato due giorni a mappare le incompatibilità. Il metodo è sempre lo stesso: prima capisco cosa si romperà, poi pianifico l'intervento. PHPCompatibility per le incompatibilità note, PHPStan per gli errori di tipo nascosti, grep manuale per i pattern che i tool automatici non trovano.
340 chiamate mysql_* - il blocco totale
L'estensione mysql_* è stata rimossa completamente in PHP 7.0. Non deprecata, rimossa. Ogni mysql_connect(), mysql_query(), mysql_fetch_array(), mysql_real_escape_string() causa un fatal error immediato. Nel codice del cliente ne ho contate 340 distribuite su 127 file. Nessuna usava prepared statement - tutte costruivano query concatenando variabili direttamente nella stringa SQL.
# Primo comando che lancio su qualsiasi codebase legacy
grep -rn "mysql_connect\|mysql_query\|mysql_fetch\|mysql_real_escape" --include="*.php" src/ | wc -l
# Output: 340La conversione non è un find-and-replace meccanico. Ogni mysql_query("SELECT * FROM products WHERE id = " . $_GET['id']) è una SQL injection in attesa di essere sfruttata. La migrazione a PDO con prepared statement è l'occasione per chiudere quelle vulnerabilità - ma richiede analisi riga per riga, perché ogni query ha i suoi parametri, i suoi tipi, i suoi casi limite.
La strategia che ho adottato è stata creare un layer di compatibilità transitorio - una classe DatabaseCompat che wrappa PDO con un'interfaccia simile alle vecchie funzioni mysql_*, ma con prepared statement sotto il cofano:
<?php
// lib/DatabaseCompat.php - layer transitorio per migrazione mysql_* → PDO
// Questo file verrà rimosso dopo la migrazione completa, quando tutte
// le query saranno convertite a PDO diretto.
class DatabaseCompat
{
private static ?PDO $pdo = null;
public static function connect(string $host, string $user, string $pass, string $db): void
{
$dsn = "mysql:host={$host};dbname={$db};charset=utf8mb4";
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
public static function query(string $sql, array $params = []): PDOStatement
{
$stmt = self::$pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public static function fetchArray(PDOStatement $stmt): ?array
{
$row = $stmt->fetch();
return $row === false ? null : $row;
}
public static function escape(string $value): string
{
// Fallback per codice legacy non ancora convertito a prepared statement
return self::$pdo->quote($value);
}
public static function insertId(): string
{
return self::$pdo->lastInsertId();
}
public static function affectedRows(PDOStatement $stmt): int
{
return $stmt->rowCount();
}
}Con questo layer, la conversione meccanica della prima passata era: mysql_connect() → DatabaseCompat::connect(), mysql_query($sql) → DatabaseCompat::query($sql), mysql_fetch_array($result) → DatabaseCompat::fetchArray($result). La seconda passata - quella che richiede intelligenza umana - era parametrizzare le query: trovare ogni concatenazione di variabile nella stringa SQL e sostituirla con un placeholder ? e un parametro nell'array.
<?php
// PRIMA (PHP 5.6 - SQL injection)
$result = mysql_query("SELECT * FROM orders WHERE client_id = " . $_GET['client_id']
. " AND status = '" . $_GET['status'] . "'");
// DOPO (PHP 8.4 - prepared statement via layer transitorio)
$result = DatabaseCompat::query(
"SELECT * FROM orders WHERE client_id = ? AND status = ?",
[$_GET['client_id'], $_GET['status']]
);La prima passata (conversione meccanica) ha richiesto un giorno. La seconda passata (parametrizzazione) ha richiesto quattro giorni - perché ogni query andava analizzata nel contesto: quali variabili erano input utente (pericolose), quali erano valori interni (meno pericolosi ma comunque da parametrizzare), quali query avevano logica condizionale nella costruzione (WHERE 1=1 AND ... con append dinamico di clausole).
Coercizione tipi: il confronto 0 == "foo" che rompeva i filtri
In PHP 5.6, 0 == "foo" restituisce true perché la stringa viene convertita a intero (0). In PHP 8, restituisce false - un comportamento più intuitivo, ma che rompe silenziosamente qualsiasi logica che dipendeva dal vecchio comportamento. Nel gestionale del cliente, il sistema di filtri del catalogo usava == per confrontare valori di form (stringhe) con valori di database (interi). Tre filtri su otto smettevano di funzionare.
<?php
// Logica filtro catalogo - rotta in PHP 8
$category_id = $_GET['cat'] ?? 0; // stringa "0" o "" se non selezionato
foreach ($products as $product) {
if ($product['category_id'] == $category_id) { // == loose comparison
// In PHP 5.6: "" == 0 è true → mostra tutti i prodotti (intenzionale)
// In PHP 8: "" == 0 è false → non mostra nulla (bug)
}
}
// Fix: rendere esplicita l'intenzione
$category_id = $_GET['cat'] ?? '';
foreach ($products as $product) {
if ($category_id === '' || (int) $product['category_id'] === (int) $category_id) {
// Esplicito: se vuoto, mostra tutto; altrimenti confronta come interi
}
}Ho trovato 23 confronti == tra variabili di tipo misto che potevano manifestare questo problema. La regola che ho applicato: ogni == sostituito con === dopo verifica del tipo atteso, oppure con cast esplicito quando i tipi differivano intenzionalmente. PHPStan a livello 6 avrebbe trovato molti di questi, ma per un codebase senza type hint il livello 6 genera migliaia di falsi positivi - ho lavorato a livello 3 e integrato con grep manuali.
strlen(null), trim(null), array_key_exists() su non-array
PHP 8 ha deprecato il passaggio di null a funzioni interne che si aspettano stringhe. strlen(null) in PHP 5.6 restituiva 0 silenziosamente; in PHP 8.1+ genera un deprecation warning, e in PHP 9 diventerà un TypeError. Nel gestionale, i campi opzionali del database tornavano come null - e venivano passati direttamente a strlen(), trim(), strtolower(), substr(). Ho contato 89 occorrenze.
<?php
// Pattern ricorrente nel codice legacy
$address = $row['address']; // null se non compilato
$trimmed = trim($address); // PHP 8: Deprecated: trim(): Passing null
$length = strlen($address); // PHP 8: Deprecated: strlen(): Passing null
// Fix sistematico con null coalescing
$address = $row['address'] ?? '';
$trimmed = trim($address); // OK
$length = strlen($address); // OKIl fix è meccanico ma va applicato ovunque. Ho scritto un pattern Rector custom per automatizzare questa conversione su tutte le variabili che provenivano da $row['campo'] - il 90% dei casi nel codebase del cliente.
Lo strumento che nessuno usa abbastanza: PHPCompatibility + Rector in combinazione
L'approccio che ho affinato in dieci anni di migrazioni è una pipeline a tre strumenti eseguiti in sequenza, non in alternativa.
Passo 1: PHPCompatibility - mappa tutte le incompatibilità note. È il censimento.
composer require --dev squizlabs/php_codesniffer phpcompatibility/php-compatibility
./vendor/bin/phpcs --config-set installed_paths vendor/phpcompatibility/php-compatibility
# Scansione completa verso PHP 8.4
./vendor/bin/phpcs -p --standard=PHPCompatibility \
--runtime-set testVersion 8.4 \
--report=csv --report-file=incompatibilita.csv \
--extensions=php src/Sul codebase del cliente, PHPCompatibility ha trovato 847 incompatibilità. Sembra un numero enorme, ma il 70% erano le 340 chiamate mysql_* (ognuna conta come 2-3 finding), il 15% erano le 89 occorrenze di null passato a funzioni stringa, e il restante 15% era un mix di funzioni rimosse (ereg(), split(), each(), create_function()).
Passo 2: Rector - automatizza le conversioni che hanno una trasformazione deterministica. Non tutto è automatizzabile (la parametrizzazione SQL non lo è), ma molto sì.
<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->paths([__DIR__ . '/src']);
$rectorConfig->sets([LevelSetList::UP_TO_PHP_84]);
};# Sempre dry-run prima
./vendor/bin/rector process --dry-run 2>&1 | head -100
# Poi applico selettivamente, un set alla volta
./vendor/bin/rector processRector sul codebase del cliente ha automatizzato: conversione ereg() → preg_match(), split() → explode(), each() → foreach, create_function() → closure. Ha correttamente lasciato intatte le mysql_* (non ha una regola per la conversione a PDO - giustamente, perché richiede analisi semantica).
Passo 3: PHPStan - trova gli errori che i due precedenti non vedono. Errori di tipo, metodi chiamati su variabili potenzialmente null, accessi a indici inesistenti.
composer require --dev phpstan/phpstan
# Livello 1 su codebase legacy senza type hint - già sufficiente per trovare errori gravi
./vendor/bin/phpstan analyse --level=1 src/PHPStan livello 1 ha trovato 34 errori reali: metodi chiamati su variabili che potevano essere false (return value di file_get_contents()), accessi a indici di array che potevano non esistere, e una divisione per zero in un calcolo di sconti che si manifestava solo per ordini a quantità zero. Quel bug esisteva dal 2016 - nessuno l'aveva mai notato perché nessun cliente aveva mai ordinato zero pezzi di qualcosa, tranne in un caso di test che era stato liquidato come "errore del cliente".
La strategia incrementale: quattro settimane, quattro fasi
Settimana 1: censimento e layer di compatibilità
- Giorno 1-2: PHPCompatibility + grep manuale → mappa completa (847 incompatibilità)
- Giorno 3: creazione
DatabaseCompatlayer - Giorno 4-5: conversione meccanica di tutte le
mysql_*al layer transitorio
A fine settimana 1, l'applicazione girava su PHP 8.4 in staging senza fatal error. Non era sicura (le query non erano parametrizzate), ma era funzionante - un checkpoint importante per il morale del titolare.
Settimana 2: parametrizzazione SQL e fix breaking changes
- Giorno 1-4: parametrizzazione di tutte le 340 query SQL (una per una, con verifica del contesto)
- Giorno 5: fix dei 23 confronti
==con tipi misti e delle 89 occorrenze dinulla funzioni stringa
Il lavoro più noioso ma più importante. Per ogni query ho documentato: parametri estratti, tipo atteso, valore default se null. Questo documento è diventato la prima forma di documentazione tecnica che il gestionale avesse mai avuto.
Settimana 3: Rector, PHPStan, test di regressione
- Giorno 1: Rector per le conversioni automatiche (
ereg,split,each,create_function) - Giorno 2-3: PHPStan livello 1 → fix dei 34 errori reali
- Giorno 4-5: test di regressione sui 10 flussi critici (carrello, checkout, offerta PDF, export ERP, area clienti, login, ricerca catalogo, listini, tracking, admin ordini)
I test di regressione li ho fatti con un approccio pragmatico: per ogni flusso, ho salvato l'output completo (HTML o dati) dalla versione PHP 5.6, poi l'ho confrontato con l'output dalla versione PHP 8.4 su staging. Differenze nel markup erano attese e irrilevanti; differenze nei dati erano bug. Ne ho trovati due: il filtro catalogo rotto (già fixato) e un arrotondamento diverso nel calcolo IVA per importi sotto i 10 centesimi - causato da un round() con precisione diversa tra PHP 5.6 e 8.4. Fix: esplicitare il parametro di precisione round($amount, 2).
Ho documentato la metodologia dei test in codebase legacy nell'articolo su test minimi in PHP legacy senza bloccare lo sviluppo - il principio è lo stesso anche per le migrazioni.
Settimana 4: go-live e stabilizzazione
- Giorno 1: migrazione del VPS da hosting condiviso a Hetzner Cloud CX23 (classe 4 euro/mese, 2 vCPU, 4 GB RAM, Debian 13 trixie, PHP 8.4)
- Giorno 2: deploy applicazione migrata, cutover DNS con TTL ridotto
- Giorno 3-5: monitoraggio errori, fix residui, consegna documentazione
Per la migrazione infrastrutturale ho scelto Hetzner Cloud perché il rapporto qualità/prezzo è solido per chi gestisce in autonomia, i data center sono in Germania e Finlandia (conformità GDPR nativa), e gli snapshot permettono rollback immediati. È una scelta self-managed: ti dai tu la sistemistica. Per le PMI italiane che vogliono invece un'infrastruttura gestita, NIS-compliant e con il dato che non lascia l'Italia, la prima scelta è RHX, con datacenter a Milano e Padova e gestione sistemistica inclusa.
Il cutover è avvenuto di sabato mattina alle 6 - finestra di traffico minimo per un B2B che lavora lunedì-venerdì. Ho mantenuto il vecchio hosting attivo per 30 giorni come fallback, con redirect 301 pronto a essere attivato in caso di problemi gravi. Non è servito.
Come gestisco le dipendenze quando non c'è Composer?
Il codebase del cliente non aveva Composer. Le "dipendenze" erano 8 file PHP scaricati da internet molti anni prima e messi in una cartella lib/: una libreria PDF (TCPDF 6.0.20, del 2014), un mailer (PHPMailer 5.2.9, del 2014 - con CVE note), un generatore CSV, un parser XML, e quattro file di utility vari. Nessun sistema di aggiornamento.
La strategia:
TCPDF - fork mantenuto disponibile su Packagist. Installato via Composer (che ho introdotto nel progetto), migrato da inclusione manuale a autoload PSR-4. La configurazione era diversa dalla 6.0 alla 6.7, ma le API di generazione PDF erano retrocompatibili al 95%.
PHPMailer 5.2.9 - vulnerabilità critica nota (CVE-2016-10033, remote code execution via mail() argument injection). Aggiornato a PHPMailer 6.x via Composer. L'API era cambiata radicalmente (namespace, eccezioni, configurazione SMTP), ma nel codice del cliente c'erano solo 3 punti dove veniva usato - conversione in 2 ore.
Utility varie - internalizzate. Copiate nella codebase, adattate a PHP 8.4, mantenute come codice del progetto. Per file di utility generici che non hanno aggiornamenti upstream, internalizzare è la scelta giusta - meglio codice che controlli che codice che nessuno mantiene.
Per chi sta affrontando la gestione di dipendenze in un contesto legacy, il tema della sicurezza delle dipendenze è trattato in profondità nell'articolo sulla supply chain security con Composer per Laravel e Symfony.
Quanto è costato e quanto ha risparmiato?
I numeri reali, perché credo che la trasparenza sui costi sia parte della credibilità professionale:
Costo della migrazione: 4 settimane di consulenza a tariffa standard, più il costo del nuovo VPS Hetzner (classe 4 euro/mese contro i circa 15 del vecchio hosting condiviso).
Costo evitato - riscrittura completa: la stima per una riscrittura su piattaforma moderna (Shopify, WooCommerce, o custom Laravel) partiva da 40.000 euro e 4-6 mesi. La migrazione ha raggiunto lo stesso risultato operativo - applicazione funzionante su stack supportato - a un quinto del costo e un quarto del tempo.
Costo evitato - incidente di sicurezza: un data breach su centinaia di anagrafiche B2B con dati bancari, nell'era del GDPR con l'Italia tra i paesi più sanzionati dall'EDPB, è un rischio che nessuna PMI può permettersi. La migrazione ha eliminato il vettore di attacco più evidente (runtime obsoleto + SQL injection sistematica).
Risultato a 60 giorni: zero errori PHP nei log di produzione, tempo di risposta medio migliorato del 40% (PHP 8.4 è significativamente più veloce di 5.6 a parità di codice), tutte le dipendenze aggiornate e senza CVE note, applicazione pronta per evoluzione futura (aggiunta di API REST, refactoring incrementale, possibile migrazione a framework).
Si può davvero migrare da PHP 5.6 a PHP 8 senza riscrivere l'applicazione?
Sì, nella stragrande maggioranza dei casi. La riscrittura big-bang è quasi sempre la scelta sbagliata per un'applicazione che funziona e genera fatturato: costa di più, richiede mesi e introduce il rischio di perdere logica di business sedimentata negli anni e mai documentata. La migrazione incrementale tratta il codice esistente come un asset da portare su un runtime supportato, non come un errore da cancellare. Il salto di versione non avviene in un colpo solo: si passa da 5.6 a 7.x e poi a 8.x, oppure si punta direttamente a 8.4 usando un layer di compatibilità transitorio per le estensioni rimosse, come la classe DatabaseCompat che ho mostrato sopra. Il vincolo non è tecnico, è di metodo: serve un censimento delle incompatibilità prima di toccare il codice, e una suite di test di regressione, anche minima, per non rompere ciò che funziona.
La domanda giusta non è "riscrivo o migro?", ma "qual è il percorso che mi mette su uno stack supportato nel minor tempo e con il minor rischio per il business?". Per un gestionale funzionante con dodici anni di logica dentro, la risposta è quasi sempre la migrazione incrementale.
Quanto tempo richiede una migrazione PHP legacy in produzione?
Dipende dalla dimensione della codebase e dalla densità di pattern incompatibili, ma per dare un ordine di grandezza concreto: un'applicazione procedurale da circa 47.000 righe, con 340 chiamate mysql_* da convertire a PDO e qualche centinaio di breaking change minori, è un lavoro di quattro settimane piene per un singolo consulente, censimento e go-live inclusi. Il fattore che fa esplodere i tempi non è il numero di righe, è la parametrizzazione delle query SQL (l'unica fase che non si può automatizzare) e l'eventuale assenza di un ambiente di staging dove verificare. Una codebase già a oggetti, con Composer e qualche test, si migra in giorni, non settimane.
Per il titolare, il valore non era nella tecnica - era nella certezza: l'applicazione funziona, è sicura, è su un'infrastruttura che qualcuno mantiene, e il prossimo sviluppatore che ci metterà mano non dovrà lottare con un runtime di otto anni fa. Per chi sta valutando un percorso simile e ha bisogno di capire come funziona il mio metodo di audit iniziale su un codebase legacy, consiglio la lettura dell'audit tecnico iniziale per i primi 30 giorni - il primo passo che applico sempre prima di qualsiasi migrazione. E per chi ha un'applicazione PHP legacy in produzione e vuole capire se e come migrare, o ha ricevuto la stessa comunicazione dal proprio provider, contattami - una conversazione di 30 minuti è sufficiente per capire se il percorso incrementale è fattibile o se serve un approccio diverso.