Introduzione

Qualche mese fa ho ricevuto una richiesta che ogni developer conosce bene: un applicativo web in produzione da anni, mai aggiornato, che iniziava a dare problemi. Il cliente era uno studio legale associato napoletano — civile e penale — con anni di documenti, fascicoli e dati sensibili gestiti da un sistema custom scritto in PHP 5.4 con MySQL 5.

PHP 5.4 ha raggiunto il fine vita nel 2015. Portarlo al 2026 significava attraversare tre major version di PHP, una major version di MySQL e rimettere mano a ogni angolo del codice, dalla gestione delle sessioni alle query, dalla sicurezza dei file al charset del database.

Questo articolo è il resoconto tecnico di come ho affrontato la migrazione, le criticità che ho trovato e le scelte che ho fatto.


L'ambiente di lavoro: tutto dockerizzato in locale

Prima di toccare una riga di codice in produzione, ho scaricato la codebase completa e un dump del database, e ho dockerizzato l'intero progetto in locale.

# docker-compose.yml (semplificato)
services:
  app:
    image: php:8.3-apache
    volumes:
      - ./src:/var/www/html
    depends_on:
      - db

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: gestionale
    volumes:
      - ./dump.sql:/docker-entrypoint-initdb.d/dump.sql

Questo approccio mi ha permesso di:

Lavorare su un legacy senza ambiente locale isolato è come operare un paziente sveglio. Docker in questi casi non è un optional — è la condizione minima per lavorare in sicurezza.


Problema 1: la gestione delle sessioni

La criticità più impattante. L'applicativo usava un gestore di sessioni custom basato su funzionalità built-in di PHP non più presenti in PHP 8.3. Al primo avvio, nessun utente riusciva ad autenticarsi.

La soluzione è stata riscrivere il session handler implementando l'interfaccia SessionHandlerInterface, che PHP fornisce esattamente per questo scopo:

class DatabaseSessionHandler implements SessionHandlerInterface
{
    public function open(string $path, string $name): bool { return true; }
    public function close(): bool { return true; }

    public function read(string $id): string
    {
        $stmt = $this->pdo->prepare(
            'SELECT data FROM sessions WHERE id = ? AND expires_at > NOW()'
        );
        $stmt->execute([$id]);
        return $stmt->fetchColumn() ?: '';
    }

    public function write(string $id, string $data): bool
    {
        $stmt = $this->pdo->prepare('
            INSERT INTO sessions (id, data, expires_at)
            VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 2 HOUR))
            ON DUPLICATE KEY UPDATE data = VALUES(data), expires_at = VALUES(expires_at)
        ');
        return $stmt->execute([$id, $data]);
    }

    public function destroy(string $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE id = ?');
        return $stmt->execute([$id]);
    }

    public function gc(int $max_lifetime): int|false
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
        $stmt->execute();
        return $stmt->rowCount();
    }
}

session_set_save_handler(new DatabaseSessionHandler($pdo));

Il vantaggio collaterale: le sessioni ora vivono nel database, sono visibili, scadono correttamente e il comportamento è prevedibile e testabile.


Problema 2: da mysql_* a PDO, con bind dei parametri

Nessuna delle query del sistema usava bind dei parametri. Tutte le variabili venivano concatenate direttamente nella stringa SQL — un rischio di SQL injection su ogni singola operazione.

Il porting a PDO ha risolto sia la compatibilità con PHP 8.3 (le funzioni mysql_* sono state rimosse in PHP 7) sia il problema di sicurezza:

// Prima — deprecato, insicuro
$result = mysql_query(
    "SELECT * FROM fascicoli WHERE cliente_id = " . $_GET['id']
);

// Dopo — PDO con bind
$stmt = $pdo->prepare(
    'SELECT * FROM fascicoli WHERE cliente_id = ?'
);
$stmt->execute([$_GET['id']]);
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);

Ho creato una classe wrapper per centralizzare la connessione e uniformare i pattern di query in tutto il codebase, così da non riscrivere la stessa logica di connessione in ogni file.


Problema 3: credenziali in chiaro nel database

Le password degli utenti erano salvate in chiaro nella tabella utenti. Prima di qualsiasi altra modifica, ho scritto uno script di migrazione che le ha hashate con password_hash:

$utenti = $pdo->query('SELECT id, password FROM utenti')->fetchAll();

foreach ($utenti as $utente) {
    $hashed = password_hash($utente['password'], PASSWORD_BCRYPT);
    $stmt = $pdo->prepare('UPDATE utenti SET password = ? WHERE id = ?');
    $stmt->execute([$hashed, $utente['id']]);
}

Il login è stato aggiornato di conseguenza per usare password_verify:

if (password_verify($inputPassword, $utenteRow['password'])) {
    // accesso consentito
}

Problema 4: file allegati scaricabili da URL diretta

Gli allegati dei fascicoli — documenti legali sensibili — erano serviti staticamente da Apache. Chiunque avesse indovinato l'URL poteva scaricarli senza autenticazione.

La soluzione in due parti:

1. Blocco della cartella con .htaccess:

# /uploads/.htaccess
Order Deny,Allow
Deny from all

2. Download manager centralizzato in PHP:

// download.php
session_start();

if (!isset($_SESSION['user_id'])) {
    http_response_code(403);
    exit('Accesso negato');
}

$fileId = (int) $_GET['id'];
$stmt = $pdo->prepare(
    'SELECT path, nome_originale FROM allegati WHERE id = ?'
);
$stmt->execute([$fileId]);
$file = $stmt->fetch();

if (!$file || !file_exists($file['path'])) {
    http_response_code(404);
    exit('File non trovato');
}

// Serve il file per download o preview
$mime = mime_content_type($file['path']);
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . $file['nome_originale'] . '"');
readfile($file['path']);

Ogni download passa ora dal controllo della sessione. Nessun file è raggiungibile direttamente.


Problema 5: charset e MySQL 8 strict mode

La migrazione a MySQL 8 con utf8mb4_unicode_ci ha causato una serie di errori a cascata, perché MySQL 8 in strict mode non accetta 0000-00-00 00:00:00 come valore di default per i campi DATETIME.

Ho dovuto fare refactoring di tutti i campi data con default non validi:

-- Prima
ALTER TABLE fascicoli 
  MODIFY data_apertura DATETIME DEFAULT '0000-00-00 00:00:00';

-- Dopo
ALTER TABLE fascicoli 
  MODIFY data_apertura DATETIME NULL DEFAULT NULL;

E ho scritto uno script per correggere i valori già presenti nei record storici:

UPDATE fascicoli 
SET data_apertura = NULL 
WHERE data_apertura = '0000-00-00 00:00:00';

Problema 6: encoding corrotto nei campi di testo

Il cambio di charset da utf8 a utf8mb4 ha rivelato dati con encoding corrotto nei campi VARCHAR e TEXT — caratteri speciali e accentate storpiati, un problema comune quando i dati erano stati inseriti con charset inconsistenti nel tempo.

Ho scritto uno script PHP per identificare e correggere i record problematici:

$rows = $pdo->query('SELECT id, testo FROM note')->fetchAll();

foreach ($rows as $row) {
    $corretto = mb_convert_encoding(
        $row['testo'], 
        'UTF-8', 
        'UTF-8'
    );

    if ($corretto !== $row['testo']) {
        $stmt = $pdo->prepare('UPDATE note SET testo = ? WHERE id = ?');
        $stmt->execute([$corretto, $row['id']]);
    }
}

Problema 7: slow query e mancanza di indici

Con un database cresciuto per anni senza manutenzione, alcune query impiegavano diversi secondi. Ho abilitato lo slow query log di MySQL per identificare i colli di bottiglia:

-- my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1

L'analisi con EXPLAIN ha rivelato full table scan su tabelle con centinaia di migliaia di righe. Ho creato gli indici mancanti sui campi usati nelle WHERE e nelle JOIN più frequenti:

CREATE INDEX idx_fascicoli_cliente 
  ON fascicoli(cliente_id);

CREATE INDEX idx_allegati_fascicolo 
  ON allegati(fascicolo_id);

CREATE INDEX idx_note_data 
  ON note(created_at);

Risultato: le query più lente sono passate da 3-4 secondi a pochi millisecondi.


Il deploy: notturno, controllato, reversibile

Prima del deploy in produzione ho creato un ambiente di pre-produzione identico a quello finale, su cui il cliente ha testato tutte le funzionalità e ha certificato il corretto funzionamento.

Il deploy è avvenuto di notte, quando il sistema non era in uso, con questa sequenza:

1. Maintenance page attiva per bloccare accessi e impedire modifiche al database durante il sync:

// maintenance.php — servito da .htaccess se MAINTENANCE=1
http_response_code(503);
echo "Sistema in manutenzione. Torneremo operativi a breve.";
exit;

2. Accesso limitato al solo mio IP tramite .htaccess e variabile d'ambiente:

# .htaccess
SetEnvIf Remote_Addr "^IL_MIO_IP$" allow_access

Order Deny,Allow
Deny from all
Allow from env=allow_access

Questo mi ha permesso di navigare l'applicativo in produzione, verificare ogni funzione critica e risolvere eventuali problemi residui prima di aprire l'accesso al cliente.

3. Sblocco finale solo dopo verifica completa.


Conclusione

Migrare un applicativo legacy non è mai solo una questione tecnica. È un lavoro di analisi, priorità e gestione del rischio. In questo caso, ogni problema aveva implicazioni concrete per uno studio legale: dati sensibili esposti, accessi non protetti, performance degradate.

L'approccio che ha funzionato:

Se gestisci applicativi legacy per i tuoi clienti, questo tipo di progetto è uno dei più formativi che puoi fare. Ti mette davanti a problemi reali che nessun tutorial copre, e ti obbliga a ragionare su sicurezza, compatibilità e continuità del servizio insieme.


FAQ

Conviene riscrivere da zero invece di fare il porting? Dipende dalla complessità del dominio applicativo. In questo caso, il sistema gestiva anni di dati e logiche specifiche dello studio legale. Una riscrittura avrebbe richiesto mesi e il rischio di perdere comportamenti impliciti consolidati. Il porting è stato la scelta corretta.

Come hai gestito i test su un'applicazione senza test automatici? Attraverso il cliente stesso, con una sessione strutturata di test su pre-produzione che copriva tutti i flussi principali: login, caricamento fascicoli, download allegati, ricerche. Non ideale, ma pragmatico per il contesto.

Quanto tempo ha richiesto il progetto? La fase di analisi e porting in locale ha richiesto la parte più lunga. Il deploy finale, grazie alla preparazione, è durato meno di due ore.

Il vecchio sistema aveva backup regolari? No — e questa è stata la prima cosa che ho sistemato prima ancora di iniziare il porting. Nessuna migrazione senza backup verificato.