PHP: progettare confini di errore e tradurre le eccezioni in risposte HTTP

by theArchitect
SHARE
PHP: progettare confini di errore e tradurre le eccezioni in risposte HTTP
© Guida-HTML5.it

Introduzione

In un’applicazione PHP moderna, un errore non dovrebbe propagarsi casualmente da una funzione all’altra fino a produrre una pagina vuota o un messaggio tecnico incomprensibile. È preferibile stabilire un confine di gestione: i livelli interni dell’applicazione generano eccezioni con informazioni utili, mentre il livello più esterno, ad esempio un controller HTTP, decide come trasformarle in una risposta adatta al client.

Questo approccio è particolarmente utile nelle API REST. Un servizio può sollevare un’eccezione perché un prodotto non esiste, perché un’operazione non è autorizzata oppure perché il database non è raggiungibile. Il controller non deve conoscere tutti i dettagli tecnici dell’errore: deve invece tradurre ciascun caso in un codice HTTP e in un formato JSON coerente.

Nel tutorial utilizzeremo:

  • eccezioni specifiche per distinguere i problemi applicativi;
  • il chaining delle eccezioni con l’operatore previous;
  • un service che non produce direttamente output HTTP;
  • un endpoint che converte le eccezioni in risposte JSON;
  • la separazione tra messaggi destinati allo sviluppatore e messaggi destinati all’utente.

Codice completo

<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;
use RuntimeException;
use Throwable;

/*
 * Eccezione applicativa: il prodotto richiesto non esiste.
 */
final class ProductNotFound extends RuntimeException
{
    public function __construct(int $productId)
    {
        parent::__construct(
            "Il prodotto con ID {$productId} non è stato trovato."
        );
    }
}

/*
 * Eccezione tecnica usata quando il database non è disponibile.
 */
final class ProductStorageException extends RuntimeException
{
}

/*
 * Repository minimale: si occupa esclusivamente dell´accesso ai dati.
 */
final class ProductRepository
{
    public function __construct(private PDO $connection)
    {
    }

    public function findById(int $id): ?array
    {
        try {
            $statement = $this->connection->prepare(
                ´SELECT id, name, price FROM products WHERE id = :id´
            );

            $statement->execute([´id´ => $id]);

            $product = $statement->fetch(PDO::FETCH_ASSOC);

            return $product === false ? null : $product;
        } catch (PDOException $exception) {
            /*
             * L´eccezione originale viene conservata come "previous".
             * In questo modo non perdiamo il dettaglio tecnico utile
             * durante il debugging.
             */
            throw new ProductStorageException(
                ´Errore durante la lettura del prodotto.´,
                0,
                $exception
            );
        }
    }
}

/*
 * Service: contiene le regole applicative, ma non conosce HTTP o JSON.
 */
final class ProductService
{
    public function __construct(
        private ProductRepository $repository
    ) {
    }

    public function getProduct(int $id): array
    {
        if ($id <= 0) {
            throw new InvalidArgumentException(
                ´L´ID del prodotto deve essere positivo.´
            );
        }

        $product = $this->repository->findById($id);

        if ($product === null) {
            throw new ProductNotFound($id);
        }

        return $product;
    }
}

/*
 * Controller o punto di ingresso HTTP.
 */
function showProduct(ProductService $service, int $id): void
{
    try {
        $product = $service->getProduct($id);

        http_response_code(200);

        echo json_encode(
            [
                ´data´ => $product,
            ],
            JSON_THROW_ON_ERROR
        );
    } catch (ProductNotFound $exception) {
        http_response_code(404);

        echo json_encode([
            ´error´ => [
                ´code´ => ´PRODUCT_NOT_FOUND´,
                ´message´ => ´Il prodotto richiesto non esiste.´,
            ],
        ]);
    } catch (InvalidArgumentException $exception) {
        http_response_code(400);

        echo json_encode([
            ´error´ => [
                ´code´ => ´INVALID_PRODUCT_ID´,
                ´message´ => ´L´ID del prodotto non è valido.´,
            ],
        ]);
    } catch (ProductStorageException $exception) {
        /*
         * Non restituiamo al client il messaggio interno del database.
         * La causa originale rimane disponibile in $exception->getPrevious().
         */
        http_response_code(503);

        echo json_encode([
            ´error´ => [
                ´code´ => ´SERVICE_UNAVAILABLE´,
                ´message´ => ´Il servizio non è momentaneamente disponibile.´,
            ],
        ]);
    } catch (Throwable $exception) {
        /*
         * Fallback per gli errori non previsti.
         */
        http_response_code(500);

        echo json_encode([
            ´error´ => [
                ´code´ => ´INTERNAL_ERROR´,
                ´message´ => ´Si è verificato un errore interno.´,
            ],
        ]);
    }
}

Spiegazione

1. Separare responsabilità e livelli

Il repository conosce il database, ma non deve decidere quale codice HTTP restituire. Il service conosce le regole applicative, ad esempio il fatto che un ID debba essere positivo, ma non dovrebbe stampare JSON. Il controller, invece, rappresenta il confine esterno dell’applicazione e può occuparsi della comunicazione HTTP.

Questa separazione rende il codice più facile da testare. Il service può essere verificato senza avviare un server web, mentre il controller può essere testato controllando la trasformazione delle eccezioni in risposte.

2. Usare eccezioni diverse per problemi diversi

ProductNotFound rappresenta un caso applicativo previsto: la richiesta è formalmente corretta, ma la risorsa non esiste. In una API questo caso corrisponde normalmente a 404 Not Found.

InvalidArgumentException indica invece un input non accettabile e viene convertita in 400 Bad Request. Infine, ProductStorageException rappresenta un problema infrastrutturale, come un database non disponibile, e viene mappata su 503 Service Unavailable.

3. Conservare la causa originale

Quando intercettiamo una PDOException, la incapsuliamo in una nuova eccezione più significativa per il dominio applicativo:

throw new ProductStorageException(
    ´Errore durante la lettura del prodotto.´,
    0,
    $exception
);

Il terzo argomento del costruttore è l’eccezione precedente. È possibile recuperarla con:

$originalException = $exception->getPrevious();

Il chaining permette di aggiungere contesto senza perdere la causa tecnica iniziale. Il client, però, non dovrebbe ricevere dettagli come query SQL, nomi delle tabelle o percorsi interni del server.

4. Ordinare correttamente i blocchi catch

Le eccezioni più specifiche devono essere intercettate prima di quelle generiche. Se mettessimo subito:

catch (Throwable $exception) {
    // ...
}

tutti gli errori verrebbero catturati da quel blocco e i casi specifici, come ProductNotFound, non raggiungerebbero mai la loro risposta dedicata. Il blocco finale con Throwable è utile come rete di sicurezza per gli errori non previsti.

Best practice

  • Non usare eccezioni per il normale flusso di controllo. Un’eccezione dovrebbe indicare una condizione anomala o un fallimento, non sostituire ogni valore di ritorno.
  • Non generare output nei livelli interni. Repository e service dovrebbero restituire dati o sollevare eccezioni, lasciando al livello HTTP la gestione della risposta.
  • Non esporre dettagli tecnici. Messaggi di PDO, stack trace e percorsi dei file sono informazioni sensibili in produzione.
  • Usare risposte JSON coerenti. Un formato stabile, con campi come code e message, semplifica il lavoro dei client.
  • Abilitare i tipi rigorosi. declare(strict_types=1) aiuta a individuare errori di tipo prima che diventino problemi più difficili da diagnosticare.
  • Non catturare e ignorare le eccezioni. Un blocco catch vuoto nasconde spesso un bug. Se un errore viene gestito, deve essere trasformato, comunicato o registrato secondo le esigenze dell’applicazione.
  • Testare ogni mappatura HTTP. Verifica almeno i casi di successo, ID non valido, prodotto assente, database non disponibile ed errore inatteso.

Riepilogo

Una gestione efficace delle eccezioni in PHP nasce dalla collaborazione tra livelli ben separati. Il repository converte gli errori tecnici in eccezioni comprensibili, il service applica le regole del dominio e il controller stabilisce come comunicare il risultato al client.

Il chaining con getPrevious() permette di mantenere la causa originale senza esporla all’esterno. La distinzione tra eccezioni specifiche consente inoltre di restituire codici HTTP corretti: 400 per input non valido, 404 per risorsa assente, 503 per problemi temporanei dell’infrastruttura e 500 per errori inattesi.

Approfondisci con risorse ufficiali

SHARE