Développement Web

Gérer les erreurs et exceptions en PHP proprement

13 min de lecture

Le code qui marche le jour de la démonstration et celui qui tient en production se distinguent surtout par une chose : la façon dont ils traitent ce qui tourne mal. Une connexion à la base qui tombe, un fichier absent, une donnée invalide, une référence en double — tout cela arrive. En PHP moderne, on ne dissémine pas des if ($erreur) partout : on lève des exceptions et on les intercepte là où l’on sait quoi faire. Dans ce tutoriel, on met en place une gestion d’erreurs propre pour « Atelier » : exceptions personnalisées, gestionnaire global, et journalisation avec Monolog.

Article de référence : ce tutoriel fait partie du guide complet du PHP moderne. Il s’appuie sur Monolog, installé dans le tutoriel Composer et autoloading.

Ce que vous allez apprendre

  • Distinguer une erreur du moteur d’une exception applicative, sous l’interface commune Throwable.
  • Lever et intercepter des exceptions avec try/catch/finally.
  • Créer une hiérarchie d’exceptions métier pour « Atelier ».
  • Enchaîner les exceptions pour conserver la cause d’origine.
  • Installer un gestionnaire global et journaliser proprement avec Monolog.

Ce que vous allez construire

Une couche de gestion d’erreurs : des exceptions PieceIntrouvable et StockInsuffisant propres au domaine, un gestionnaire d’exceptions global qui journalise et affiche un message neutre, et l’intégration de Monolog pour garder une trace de chaque incident.

Prérequis

  • PHP 8.4, le projet « Atelier » structuré avec Composer.
  • Monolog installé (composer require monolog/monolog).
  • Connaître les classes Piece et DepotPdo des tutoriels précédents.
  • ⏱️ Temps estimé : ~40 minutes.

Étape 1 — Erreur ou exception ? Comprendre Throwable

En PHP, tout ce qui peut être « lancé » implémente l’interface Throwable. Sous elle, deux familles. Les Error signalent des problèmes du moteur ou du programme lui-même : appeler une méthode inexistante, une TypeError, une division par zéro (DivisionByZeroError). Les Exception signalent des conditions applicatives qu’on peut anticiper : un fichier manquant, une validation qui échoue. La distinction guide la réaction : on attrape les exceptions qu’on sait gérer, et on laisse en général remonter les Error, symptômes de bugs à corriger.

<?php
declare(strict_types=1);

try {
    $resultat = 10 % 0; // DivisionByZeroError (un Error)
} catch (\DivisionByZeroError $e) {
    echo "Erreur moteur : " . $e->getMessage();
}

try {
    throw new \RuntimeException("Quelque chose a échoué");
} catch (\Exception $e) {
    echo "Exception : " . $e->getMessage();
}

Les deux blocs montrent qu’on peut intercepter l’un comme l’autre, mais pour des raisons différentes. On attrape le DivisionByZeroError ici à titre d’exemple ; en pratique, on préviendrait la division par zéro en amont. L’exception, elle, fait partie du flux prévu : on la lève quand une règle métier est violée et on l’intercepte là où l’on peut réagir utilement. Comme les deux descendent de Throwable, on peut même écrire catch (\Throwable $e) pour tout attraper — utile au sommet d’une application, dangereux ailleurs car cela masque les bugs.

Étape 2 — try, catch, finally

La structure complète comporte trois blocs. try contient le code susceptible d’échouer ; catch réagit à un type d’exception ; finally s’exécute dans tous les cas, qu’il y ait eu exception ou non — idéal pour libérer une ressource. Illustrons avec une lecture de fichier de configuration.

<?php
function lireConfig(string $chemin): array
{
    $fichier = null;
    try {
        $fichier = fopen($chemin, 'r');
        if ($fichier === false) {
            throw new \RuntimeException("Impossible d'ouvrir {$chemin}");
        }
        $contenu = fread($fichier, 8192);
        return json_decode($contenu, true, flags: JSON_THROW_ON_ERROR);
    } catch (\JsonException $e) {
        throw new \RuntimeException("Configuration JSON invalide", previous: $e);
    } finally {
        if (is_resource($fichier)) {
            fclose($fichier); // toujours fermé, succès ou échec
        }
    }
}

Le bloc finally garantit que le fichier est fermé même si une exception interrompt le try : on n’oublie jamais de libérer la ressource. Remarquez JSON_THROW_ON_ERROR : depuis PHP 7.3, on peut demander à json_decode() de lever une JsonException au lieu de renvoyer null silencieusement — un exemple parfait d’API qui privilégie l’exception au code de retour ambigu. On verra à l’étape suivante le rôle du paramètre previous.

Étape 3 — Des exceptions métier pour « Atelier »

Les exceptions génériques (RuntimeException, InvalidArgumentException) suffisent pour démarrer, mais des exceptions nommées rendent le code bien plus expressif et permettent de réagir spécifiquement. Créons une hiérarchie propre au domaine. On part d’une exception de base, dont héritent les exceptions précises, ce qui permet d’attraper soit le cas précis, soit toute la famille.

<?php
declare(strict_types=1);

namespace Atelier\Exception;

// Base commune à toutes les exceptions du domaine
class AtelierException extends \RuntimeException {}

final class PieceIntrouvable extends AtelierException
{
    public static function pourReference(string $reference): self
    {
        return new self("Aucune pièce avec la référence « {$reference} ».");
    }
}

final class StockInsuffisant extends AtelierException
{
    public function __construct(
        public readonly string $reference,
        public readonly int $demande,
        public readonly int $disponible,
    ) {
        parent::__construct(
            "Stock insuffisant pour {$reference} : {$demande} demandées, {$disponible} disponibles."
        );
    }
}

Deux techniques utiles apparaissent. PieceIntrouvable::pourReference() est un constructeur nommé : une méthode statique qui fabrique l’exception avec un message cohérent, plus lisible que d’écrire le message à chaque throw. Et StockInsuffisant transporte des données structurées (référence, quantité demandée, disponible) dans des propriétés en lecture seule : celui qui l’attrape peut les exploiter au lieu de parser un message texte. Hériter d’une base AtelierException commune permet d’écrire catch (AtelierException $e) pour traiter d’un coup tout incident métier.

<?php
// Usage dans le dépôt
public function sortir(string $reference, int $nombre): void
{
    $piece = $this->parReference($reference)
        ?? throw PieceIntrouvable::pourReference($reference);

    if ($nombre > $piece->quantite) {
        throw new StockInsuffisant($reference, $nombre, $piece->quantite);
    }
    // ... mise à jour ...
}

L’expression ?? throw ... (le throw est une expression depuis PHP 8.0) condense le « si null, lève une exception » en une ligne lisible. Le code exprime exactement l’intention : une pièce introuvable et un stock insuffisant sont deux situations distinctes, chacune avec son type.

Point d’étape — Appelez sortir('INCONNUE', 5) dans un try/catch (PieceIntrouvable $e) et affichez le message. Vous devez voir « Aucune pièce avec la référence « INCONNUE » ». Puis attrapez StockInsuffisant et lisez $e->disponible : la donnée structurée doit être accessible directement.

Étape 4 — Enchaîner les exceptions pour garder la cause

Quand on intercepte une exception technique pour en relancer une plus parlante, il ne faut pas perdre la cause d’origine. Le troisième argument du constructeur d’exception, previous, sert exactement à cela : il chaîne l’exception courante à celle qui l’a provoquée. La pile complète reste consultable pour le diagnostic.

<?php
use Atelier\Exception\AtelierException;

public function ajouter(Piece $piece): void
{
    try {
        // ... INSERT via PDO ...
    } catch (\PDOException $e) {
        // On masque le détail SQL, mais on conserve la cause
        throw new AtelierException(
            "Échec de l'enregistrement de la pièce {$piece->reference}.",
            previous: $e
        );
    }
}

L’appelant reçoit une AtelierException claire, sans détail technique sur la base ; mais en journalisant, on accède à la PDOException d’origine via $e->getPrevious(), avec son message SQL précis et sa trace. On obtient le meilleur des deux mondes : un message propre pour l’utilisateur, toute l’information pour le développeur. Ce découplage entre la couche technique et la couche métier est un signe de code mûr.

Étape 5 — Un gestionnaire d’exceptions global

Malgré tous les try/catch bien placés, une exception peut échapper à tout le monde. Plutôt que de laisser PHP afficher une trace brute au visiteur — qui révèle des chemins de fichiers et parfois des secrets — on installe un gestionnaire global avec set_exception_handler(). Il attrape toute exception non interceptée, la journalise, et présente un message neutre.

<?php
declare(strict_types=1);

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Level;

$log = new Logger('atelier');
$log->pushHandler(new StreamHandler(__DIR__ . '/../var/atelier.log', Level::Warning));

set_exception_handler(function (\Throwable $e) use ($log): void {
    $log->error($e->getMessage(), [
        'exception' => $e::class,
        'fichier'   => $e->getFile(),
        'ligne'     => $e->getLine(),
        'cause'     => $e->getPrevious()?->getMessage(),
    ]);

    http_response_code(500);
    echo "Une erreur est survenue. L'équipe technique a été notifiée.";
});

À partir de ce point, toute exception non rattrapée est journalisée avec son type, son emplacement et sa cause éventuelle, puis le visiteur reçoit un message rassurant et un code HTTP 500 — jamais la trace brute. Le contexte passé à $log->error() est un tableau structuré : Monolog l’écrit proprement, ce qui rend les journaux exploitables. C’est exactement le genre de filet de sécurité qu’on veut au sommet de toute application en production.

Étape 6 — Gérer aussi les erreurs et les arrêts fatals

Les exceptions ne couvrent pas tout. Certaines erreurs historiques de PHP (un avertissement, une notice) ne sont pas des exceptions ; et une erreur fatale peut arrêter le script sans passer par le gestionnaire d’exceptions. On complète donc le dispositif avec set_error_handler(), qui convertit les erreurs en exceptions, et register_shutdown_function(), qui capte les arrêts fatals.

<?php
// Transformer les erreurs PHP en exceptions
set_error_handler(function (int $niveau, string $message, string $fichier, int $ligne): bool {
    if (!(error_reporting() & $niveau)) {
        return false; // erreur masquée par @ ou la configuration
    }
    throw new \ErrorException($message, 0, $niveau, $fichier, $ligne);
});

// Capter les erreurs fatales en fin de script
register_shutdown_function(function () use ($log): void {
    $erreur = error_get_last();
    if ($erreur !== null && in_array($erreur['type'], [E_ERROR, E_PARSE, E_CORE_ERROR], true)) {
        $log->critical('Arrêt fatal', $erreur);
    }
});

Avec set_error_handler, un avertissement comme un accès à un index de tableau inexistant devient une ErrorException que l’on peut attraper et journaliser comme le reste — fini les erreurs silencieuses. register_shutdown_function est le dernier rempart : même quand le moteur s’arrête brutalement (mémoire épuisée, erreur de syntaxe dans un fichier inclus), on garde une trace dans le journal. Ce trio — gestionnaire d’exceptions, gestionnaire d’erreurs, fonction d’arrêt — forme un filet complet.

Point d’étape — Provoquez volontairement une exception non interceptée (par exemple throw new \RuntimeException('test') sans try). Le visiteur doit voir le message neutre, et le fichier var/atelier.log doit contenir une ligne d’erreur avec le type et l’emplacement. Si le journal reste vide, vérifiez les droits d’écriture sur le dossier var/.

Étape 7 — Configuration : développement contre production

Le comportement face aux erreurs doit changer selon l’environnement. En développement, on veut tout voir, immédiatement, à l’écran. En production, on ne montre jamais une erreur au visiteur : on la journalise en silence. Ces réglages se font dans php.ini ou au démarrage du script.

<?php
// Au sommet du point d'entrée, selon l'environnement
$enProduction = getenv('APP_ENV') === 'production';

error_reporting(E_ALL);              // toujours tout signaler en interne
ini_set('display_errors', $enProduction ? '0' : '1');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../var/php-errors.log');

La clé est de découpler signaler (toujours E_ALL) de afficher (display_errors à 0 en production). Laisser display_errors actif en production est une faille classique : les messages d’erreur révèlent l’arborescence des fichiers, parfois des identifiants de base, et offrent une carte à un attaquant. On affiche en développement, on journalise partout, et on n’expose jamais rien au visiteur en production.

Étape 8 — Vérification finale

Mettez en place le point d’entrée complet : configuration selon l’environnement, gestionnaires global d’exceptions et d’erreurs, fonction d’arrêt, et Monolog. Déclenchez successivement une PieceIntrouvable, un StockInsuffisant et une exception non interceptée. Chaque cas doit produire la bonne réaction : message métier attrapé et traité pour les deux premiers, message neutre + journal pour le troisième. Quand votre application réagit avec calme et traçabilité à chaque type de problème, sa gestion d’erreurs est à niveau.

Pièges fréquents

Symptôme / erreur Cause probable Correctif
Une exception passe inaperçue catch vide qui avale l’erreur Au minimum journaliser ; ne jamais laisser un catch muet
Trace brute affichée au visiteur display_errors actif en production Le passer à 0 et installer un gestionnaire global
La cause d’origine est perdue previous non transmis lors du re-lancement Toujours passer previous: $e en ré-emballant
Le finally ne libère pas la ressource La ressource est null ou déjà fermée Vérifier avec is_resource() avant de fermer
Le journal reste vide Droits d’écriture manquants sur var/ Donner les droits d’écriture au serveur web sur le dossier

Récapitulatif

Vous avez doté « Atelier » d’une gestion d’erreurs digne de la production. La distinction Error/Exception sous Throwable clarifie quoi attraper ; try/catch/finally encadre le code risqué et libère les ressources ; les exceptions métier nommées rendent les incidents expressifs et exploitables ; l’enchaînement par previous conserve la cause technique sous un message propre ; et le trio gestionnaire d’exceptions / gestionnaire d’erreurs / fonction d’arrêt, couplé à Monolog, garantit qu’aucun incident ne passe sans laisser de trace. Le code ne se contente plus de marcher : il échoue proprement.

Aide-mémoire

Élément Rôle
throw new Exception(...) Lever une exception
try { } catch (T $e) { } finally { } Intercepter et toujours nettoyer
catch (A | B $e) Attraper plusieurs types d’un coup
new X("msg", previous: $e) Enchaîner à la cause d’origine
$e->getPrevious() Récupérer l’exception sous-jacente
set_exception_handler() Gestionnaire global d’exceptions
set_error_handler() Convertir les erreurs en exceptions
register_shutdown_function() Capter les arrêts fatals

À vous de jouer

Ajoutez une exception ReferenceInvalide (héritant d’AtelierException) levée quand une référence ne respecte pas le format attendu (lettres, chiffres et tirets), et validez-la à la construction d’une pièce.

Voir une solution
final class ReferenceInvalide extends AtelierException
{
    public static function pour(string $ref): self
    {
        return new self("Référence invalide : « {$ref} ».");
    }
}

// À la validation :
if (!preg_match('/^[A-Z0-9-]{3,32}$/', $reference)) {
    throw ReferenceInvalide::pour($reference);
}

Tutoriels liés

Pour aller plus loin

Foire aux questions

Faut-il attraper Throwable ou Exception ?

Au sommet de l’application, dans le gestionnaire global, Throwable a du sens pour ne rien laisser passer. Mais dans le code courant, on attrape des types précis (PDOException, StockInsuffisant) afin de réagir spécifiquement et de ne pas masquer des bugs. Attraper Throwable au milieu du code cache souvent des erreurs qu’on aurait dû corriger.

Quand créer une exception personnalisée plutôt qu’utiliser RuntimeException ?

Dès qu’un incident a un sens métier propre et qu’on pourrait vouloir y réagir spécifiquement. « Pièce introuvable » et « stock insuffisant » méritent leurs types ; une erreur ponctuelle sans réaction dédiée peut rester une RuntimeException. La règle : un type d’exception par décision distincte côté appelant.

Le bloc finally s’exécute-t-il même en cas de return dans le try ?

Oui. finally s’exécute quoi qu’il arrive : exception, return normal, ou même return dans le catch. C’est précisément pour cela qu’on y place la libération des ressources : on est certain qu’il passera.

Partager