Migrare un gestionale PHP 5.6 a PHP 8.4 senza riscriverlo: il caso di un e-commerce torinese con 12 anni di codice procedurale

Migrare un gestionale PHP 5.6 a PHP 8.4 senza riscriverlo: il caso di un e-commerce torinese con 12 anni di codice procedurale

Il 4 febbraio 2026 mi ha contattato il titolare di un piccolo e-commerce torinese che vende componenti per automazione industriale - valvole pneumatiche, attuatori, sensori di pressione - a una clientela B2B di circa 400 aziende manifatturiere nel nord Italia. Il gestionale era un'applicazione PHP custom costruita nel 2014 da uno sviluppatore freelance che aveva lasciato il progetto nel 2019: 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 il 5 aprile 2026. Aggiornate le vostre applicazioni."

Il titolare aveva sessanta giorni. L'applicazione gestiva il catalogo prodotti (4.200 SKU), il carrello B2B con listini personalizzati per cliente, la generazione di offerte PDF, la sincronizzazione degli ordini con il gestionale SAP del magazzino via file CSV esportati ogni notte, e l'area riservata per i clienti con storico ordini e tracking delle spedizioni. Fatturato transitato: circa 2,8 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 4.200 SKU, listini personalizzati per 400 clienti, logica di offerte PDF, integrazione SAP - 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 400 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.

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 torinese

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: 340

La 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);      // OK

Il 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 process

Rector 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 DatabaseCompat layer
  • 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 di null a 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 SAP, 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 CX22 (€4.5/mese, 2 vCPU, 4 GB RAM, Debian 12, 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 è imbattibile per PMI europee, i data center sono in Germania e Finlandia (conformità GDPR nativa), e gli snapshot permettono rollback immediati. Per nuovi clienti, puoi ottenere €20.00 di credito gratuito utilizzando questo link con codice sconto.

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 nel 2014 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 (€4.5/mese vs €15/mese 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 400 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).

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.

Ultima modifica: