PHP: transazioni sicure e gestione delle eccezioni con PDO

by theArchitect
SHARE
PHP: transazioni sicure e gestione delle eccezioni con PDO
© Guida-HTML5.it

Introduzione

Quando un’applicazione PHP modifica più dati collegati tra loro, non è sufficiente controllare ogni singola query. È necessario garantire che tutte le operazioni vengano completate correttamente oppure che nessuna modifica venga mantenuta. Questo problema si presenta, per esempio, durante la creazione di un ordine: bisogna inserire l’ordine principale, aggiungere le righe dei prodotti e aggiornare le quantità disponibili.

Se una query fallisce dopo che le precedenti sono già state eseguite, il database potrebbe rimanere in uno stato incoerente. Una transazione risolve questo problema raggruppando più operazioni in un’unica unità di lavoro. In caso di successo si esegue il commit; se si verifica un errore, si esegue il rollback per annullare tutte le modifiche.

In PHP, PDO permette di combinare le transazioni con le eccezioni. Questa tecnica è utile perché separa chiaramente tre responsabilità:

  • la logica dell’operazione;
  • la gestione del fallimento e il rollback;
  • la comunicazione dell’errore al livello superiore dell’applicazione.

Codice completo

<?php

declare(strict_types=1);

/**
 * Crea un ordine e aggiorna il magazzino in modo atomico.
 *
 * @param PDO   $pdo
 * @param int   $utenteId
 * @param array $prodotti Esempio: [[´id´ => 10, ´quantita´ => 2]]
 *
 * @return int ID del nuovo ordine
 *
 * @throws RuntimeException Se un prodotto non esiste o non è disponibile
 * @throws Throwable        Per altri errori del database
 */
function creaOrdine(PDO $pdo, int $utenteId, array $prodotti): int
{
    if ($prodotti === []) {
        throw new InvalidArgumentException(
            ´Un ordine deve contenere almeno un prodotto.´
        );
    }

    try {
        // Inizia la transazione.
        $pdo->beginTransaction();

        // Inserisce l´ordine principale.
        $stmtOrdine = $pdo->prepare(
            ´INSERT INTO ordini (utente_id, creato_il)
             VALUES (:utente_id, CURRENT_TIMESTAMP)´
        );

        $stmtOrdine->execute([
            ´utente_id´ => $utenteId,
        ]);

        $ordineId = (int) $pdo->lastInsertId();

        // Query preparate riutilizzate per ogni prodotto.
        $stmtProdotto = $pdo->prepare(
            ´SELECT prezzo, quantita_disponibile
             FROM prodotti
             WHERE id = :id
             FOR UPDATE´
        );

        $stmtRiga = $pdo->prepare(
            ´INSERT INTO righe_ordine
                (ordine_id, prodotto_id, quantita, prezzo_unitario)
             VALUES
                (:ordine_id, :prodotto_id, :quantita, :prezzo_unitario)´
        );

        $stmtMagazzino = $pdo->prepare(
            ´UPDATE prodotti
             SET quantita_disponibile = quantita_disponibile - :quantita
             WHERE id = :id´
        );

        foreach ($prodotti as $prodotto) {
            $prodottoId = (int) ($prodotto[´id´] ?? 0);
            $quantita = (int) ($prodotto[´quantita´] ?? 0);

            if ($prodottoId <= 0 || $quantita <= 0) {
                throw new InvalidArgumentException(
                    ´ID prodotto e quantità devono essere validi.´
                );
            }

            $stmtProdotto->execute([
                ´id´ => $prodottoId,
            ]);

            $datiProdotto = $stmtProdotto->fetch(PDO::FETCH_ASSOC);

            if ($datiProdotto === false) {
                throw new RuntimeException(
                    "Prodotto {$prodottoId} non trovato."
                );
            }

            if ((int) $datiProdotto[´quantita_disponibile´] < $quantita) {
                throw new RuntimeException(
                    "Quantità insufficiente per il prodotto {$prodottoId}."
                );
            }

            $stmtRiga->execute([
                ´ordine_id´       => $ordineId,
                ´prodotto_id´     => $prodottoId,
                ´quantita´        => $quantita,
                ´prezzo_unitario´ => $datiProdotto[´prezzo´],
            ]);

            $stmtMagazzino->execute([
                ´quantita´ => $quantita,
                ´id´       => $prodottoId,
            ]);
        }

        // Rende permanenti tutte le modifiche.
        $pdo->commit();

        return $ordineId;
    } catch (Throwable $e) {
        // Esegue il rollback solo se una transazione è ancora attiva.
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        // L´errore viene propagato al chiamante.
        throw $e;
    }
}

$pdo = new PDO(
    ´mysql:host=localhost;dbname=negozio;charset=utf8mb4´,
    ´app_user´,
    ´password´,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]
);

try {
    $ordineId = creaOrdine($pdo, 25, [
        [´id´ => 10, ´quantita´ => 2],
        [´id´ => 14, ´quantita´ => 1],
    ]);

    echo "Ordine creato con ID: {$ordineId}";
} catch (InvalidArgumentException $e) {
    echo ´Dati non validi: ´ . $e->getMessage();
} catch (RuntimeException $e) {
    echo ´Operazione non completata: ´ . $e->getMessage();
} catch (Throwable $e) {
    // In produzione non mostrare dettagli tecnici all´utente.
    error_log((string) $e);
    echo ´Si è verificato un errore imprevisto.´;
}

Spiegazione

Configurare PDO per usare le eccezioni

La configurazione PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION obbliga PDO a generare un’eccezione quando una query fallisce. Senza questa opzione, alcuni errori potrebbero essere restituiti solamente tramite valori di ritorno, aumentando il rischio di dimenticare un controllo.

Le query preparate tramite prepare() e execute() impediscono inoltre di concatenare direttamente dati forniti dall’utente nelle istruzioni SQL. Questo migliora la sicurezza e rende il codice più leggibile.

Il ruolo di beginTransaction, commit e rollback

beginTransaction() apre la transazione. Tutte le query successive fanno parte della stessa unità di lavoro. Se ogni passaggio termina correttamente, commit() conferma le modifiche. Se viene lanciata un’eccezione, il blocco catch chiama rollBack().

Il controllo con inTransaction() è importante perché evita di chiamare il rollback quando la transazione non è più attiva. Dopo il rollback, l’eccezione viene rilanciata con throw $e: la funzione non nasconde il problema, ma lascia al chiamante la decisione su come comunicarlo.

Perché usare FOR UPDATE

La clausola FOR UPDATE blocca le righe dei prodotti lette durante la transazione. In questo modo due richieste simultanee non possono verificare entrambe la stessa disponibilità e vendere più pezzi di quelli realmente presenti. Questa funzionalità richiede un database e un motore di tabelle che supportino le transazioni, come InnoDB in MySQL.

Best practice

  • Mantieni le transazioni brevi: evita operazioni lente, chiamate HTTP o elaborazioni non necessarie tra l’inizio e la fine della transazione.
  • Non catturare le eccezioni troppo presto: il livello che possiede il contesto, per esempio il controller o il servizio applicativo, dovrebbe decidere come gestirle.
  • Non mostrare dettagli SQL agli utenti: registra l’errore internamente, ma restituisci un messaggio generico nelle applicazioni pubbliche.
  • Usa transazioni solo quando servono: una semplice lettura non richiede normalmente una transazione esplicita.
  • Verifica il motore del database: tabelle non transazionali possono ignorare il rollback.
  • Gestisci i deadlock: in sistemi con molte richieste concorrenti, valuta un numero limitato di tentativi automatici con una piccola pausa.
  • Definisci vincoli nel database: chiavi esterne, indici univoci e controlli di integrità completano la validazione applicativa.

Riepilogo

Le transazioni PDO permettono di mantenere coerenti più modifiche al database. Una struttura affidabile prevede l’avvio con beginTransaction(), l’esecuzione delle query in un blocco try, la conferma con commit() e il ripristino con rollBack() in caso di eccezione.

La configurazione di PDO in modalità eccezione rende espliciti gli errori, mentre il rilancio dell’eccezione consente ai livelli superiori di applicare una gestione appropriata. In applicazioni con dati condivisi, il blocco delle righe e i vincoli del database aiutano inoltre a prevenire condizioni di gara e risultati incoerenti.

Approfondisci con risorse ufficiali

SHARE