Leçon 6/7 10 min

Les exceptions

throw, try/catch et hiérarchie d'exceptions en PHP. Gérer les erreurs proprement, sans codes retour muets.

Tu as déjà croisé une exception sans la comprendre

À la leçon 2, dans le setter de Produit, on a écrit ceci sans s'arrêter dessus :

public function setPrix(float $prix): void {
    if ($prix < 0) {
        throw new InvalidArgumentException("Prix négatif interdit");
    }
    $this->prix = $prix;
}

Ce throw new InvalidArgumentException, il est temps de comprendre ce qu'il fait vraiment. Il répond à une question qui revient sans cesse : que fait une méthode quand elle ne PEUT PAS faire son travail ? Un prix négatif, un solde trop bas, un fichier absent.

La mauvaise réponse, c'est le code d'erreur. retirer() renvoie false quand ça échoue. Une autre méthode renvoie null. Une troisième renvoie -1. L'appelant doit deviner la convention de chacune, et surtout penser à tester le retour à chaque appel. Un oubli, et le programme continue avec une valeur fausse. C'est le chaos des codes d'erreur.

⚠️ Attention : un false ou un null de retour ne force personne à le regarder. Le code qui ignore l'erreur compile, tourne, et propage des données corrompues en silence. C'est exactement le bug qu'une exception rend impossible à ignorer.

throw : arrêter net avec un objet qui raconte tout

Lever une exception, c'est dire « je ne peux pas continuer » et arrêter la méthode immédiatement. À la ligne du throw, l'exécution de la méthode s'interrompt : les lignes suivantes ne tournent pas. PHP remonte alors la pile d'appels à la recherche de quelqu'un pour gérer le problème.

Et ce n'est pas un simple message : une exception est un objet. Comme tout objet (leçon 1), elle porte des données et des méthodes. Elle sait raconter ce qui s'est passé :

  • $e->getMessage() : le message lisible que tu as écrit.
  • $e->getFile() et $e->getLine() : le fichier et la ligne exacts du throw.
  • $e->getCode() : un code numérique optionnel.
  • $e->getTrace() : toute la pile d'appels qui a mené là.
function diviser(int $a, int $b): float {
    if ($b === 0) {
        throw new InvalidArgumentException("Division par zéro interdite");
    }
    return $a / $b;   // sauté si b vaut 0
}

Si personne ne l'attrape, l'exception devient une erreur fatale et le script s'arrête. Ce n'est pas un défaut : mieux vaut un arrêt net et bruyant qu'un résultat faux qui se propage. La suite montre comment l'attraper proprement.

try / catch : attraper le problème

Pour gérer une exception au lieu de planter, on entoure le code risqué d'un bloc try, suivi d'un catch qui récupère l'objet exception. Le flux d'exécution est précis :

  • Le code du try s'exécute normalement, ligne après ligne.
  • Si un throw survient (ici ou dans une méthode appelée), PHP saute tout le reste du try et bondit dans le catch.
  • Le catch reçoit l'objet exception, le traite, puis l'exécution reprend normalement après le bloc.
  • Si aucun throw ne survient, le catch est tout simplement ignoré.
try {
    $r = diviser(10, 0);
    echo "Résultat : $r";     // sauté : le throw a eu lieu avant
} catch (InvalidArgumentException $e) {
    echo "Souci : " . $e->getMessage();   // on récupère le contrôle ici
}
echo "\nLe programme continue.";   // exécuté dans tous les cas

Et finally ? Un bloc optionnel après le catch, exécuté quoi qu'il arrive : succès, exception attrapée, ou même exception relancée. C'est l'endroit du nettoyage qu'on ne veut jamais oublier : fermer un fichier, libérer une connexion à la base.

Lever et attraper : exécute

Un compte qui refuse un retrait à découvert, non plus avec un false muet (leçon 2), mais avec une exception qui explique le problème. Note bien : le programme continue après le catch.

<?php
class Compte {
    private float $solde = 0;

    public function deposer(float $m): void { $this->solde += $m; }

    public function retirer(float $m): void {
        if ($m > $this->solde) {
            throw new RuntimeException(
                "Solde insuffisant : il manque " . ($m - $this->solde) . " €"
            );
        }
        $this->solde -= $m;
    }
    public function getSolde(): float { return $this->solde; }
}

$c = new Compte();
$c->deposer(50);

try {
    $c->retirer(200);                 // lève l'exception
    echo "Retrait effectué\n";       // sauté
} catch (RuntimeException $e) {
    echo "Erreur : " . $e->getMessage() . "\n";
} finally {
    echo "Solde : " . $c->getSolde() . " €\n";   // toujours exécuté
}

echo "Le programme continue normalement.\n";
?>

La hiérarchie : du spécifique au général

Toutes les exceptions descendent d'une seule classe de base : Exception. PHP fournit ensuite une famille toute prête, les exceptions SPL, qui en héritent :

  • InvalidArgumentException : un argument reçu est invalide (prix négatif, format faux).
  • RuntimeException : un problème survenu à l'exécution (solde trop bas, ressource indisponible).
  • LogicException : une erreur de logique du programme, qu'on aurait dû éviter en codant.

Et c'est ici que l'héritage de la leçon 3 paie comptant. Un catch attrape une classe et toutes ses classes filles : c'est du polymorphisme. Comme InvalidArgumentException hérite (indirectement) d'Exception, un catch (Exception $e) attrape aussi les InvalidArgumentException.

Conséquence directe : on attrape du plus spécifique au plus général. Un catch trop large placé en premier raflerait tout, et les catch plus précis en dessous ne serviraient jamais.

<?php
function valider(int $age): void {
    if ($age < 0)   throw new InvalidArgumentException("Âge négatif");
    if ($age > 150) throw new RuntimeException("Âge irréaliste");
}

foreach ([-5, 200, 30] as $age) {
    try {
        valider($age);
        echo "$age : valide\n";
    } catch (InvalidArgumentException $e) {     // le plus spécifique d'abord
        echo "$age : argument invalide (" . $e->getMessage() . ")\n";
    } catch (Exception $e) {                    // le filet de sécurité, en dernier
        echo "$age : autre erreur (" . $e->getMessage() . ")\n";
    }
}
?>
⚠️ Attention : si tu mets catch (Exception $e) avant catch (InvalidArgumentException $e), le premier attrape tout (l'héritage joue) et le second devient du code mort. Toujours du spécifique vers le général.

Tes propres exceptions : du métier dans le nom

Réutiliser une exception SPL suffit souvent. Mais parfois, le nom de l'erreur doit parler le langage de ton domaine. CommandeIntrouvableException raconte le problème métier bien mieux qu'un RuntimeException générique. Et créer la sienne est trivial : il suffit d'étendre Exception (encore l'héritage de la leçon 3).

<?php
// Une exception métier : son nom dit déjà tout
class CommandeIntrouvableException extends Exception {}

class Catalogue {
    private array $commandes = ["CMD-1" => 49.90, "CMD-2" => 12.00];

    public function montant(string $ref): float {
        if (!isset($this->commandes[$ref])) {
            throw new CommandeIntrouvableException("Commande $ref introuvable");
        }
        return $this->commandes[$ref];
    }
}

$cat = new Catalogue();
foreach (["CMD-1", "CMD-9"] as $ref) {
    try {
        echo "$ref : " . $cat->montant($ref) . " €\n";
    } catch (CommandeIntrouvableException $e) {   // on cible précisément ce cas
        echo "Souci métier : " . $e->getMessage() . "\n";
    }
}
?>

La règle de décision : réutilise une SPL tant qu'elle décrit bien le problème (argument invalide, état d'exécution). Crée la tienne dès que tu veux l'attraper de façon ciblée, ou quand son nom apporte une information métier que l'appelant doit pouvoir distinguer.

✦ Bonne pratique : une exception pour l'exceptionnel, pas pour le flux normal. « L'utilisateur a saisi un mot de passe trop court » est un cas prévu : valide-le avec un if et un message à l'écran. « La base de données est injoignable » est anormal : là, l'exception est légitime. Lever une exception pour piloter un déroulement attendu rend le code illisible et coûte cher en performance.

You've already met it without understanding it

In lesson 2, inside the Produit setter, we wrote this without dwelling on it:

public function setPrix(float $prix): void {
    if ($prix < 0) {
        throw new InvalidArgumentException("Negative price not allowed");
    }
    $this->prix = $prix;
}

That throw new InvalidArgumentException — it's time to understand what it really does. It answers a question that keeps coming back: what does a method do when it CANNOT do its job? A negative price, a balance too low, a missing file.

The wrong answer is the error code. retirer() returns false on failure. Another method returns null. A third returns -1. The caller must guess each one's convention, and above all remember to check the return value on every call. Miss one, and the program carries on with a wrong value. That's the chaos of error codes.

⚠️ Warning: a returned false or null forces nobody to look at it. Code that ignores the error compiles, runs, and silently propagates corrupted data. That's exactly the bug an exception makes impossible to ignore.

throw: stop dead with an object that tells the whole story

Throwing an exception means saying "I can't continue" and stopping the method immediately. At the throw line, the method's execution halts: the following lines don't run. PHP then walks up the call stack looking for someone to handle the problem.

And it's not just a message: an exception is an object. Like any object (lesson 1), it carries data and methods. It can describe what happened:

  • $e->getMessage(): the readable message you wrote.
  • $e->getFile() and $e->getLine(): the exact file and line of the throw.
  • $e->getCode(): an optional numeric code.
  • $e->getTrace(): the whole call stack that led there.
function diviser(int $a, int $b): float {
    if ($b === 0) {
        throw new InvalidArgumentException("Division by zero not allowed");
    }
    return $a / $b;   // skipped if b is 0
}

If nobody catches it, the exception becomes a fatal error and the script stops. That's not a flaw: a clean, loud stop beats a wrong result that spreads. Next we see how to catch it cleanly.

try / catch: catching the problem

To handle an exception instead of crashing, you wrap the risky code in a try block, followed by a catch that receives the exception object. The control flow is precise:

  • The try code runs normally, line by line.
  • If a throw occurs (here or in a called method), PHP skips the rest of the try and jumps into the catch.
  • The catch receives the exception object, handles it, then execution resumes normally after the block.
  • If no throw occurs, the catch is simply ignored.
try {
    $r = diviser(10, 0);
    echo "Result: $r";        // skipped: the throw happened before
} catch (InvalidArgumentException $e) {
    echo "Problem: " . $e->getMessage();   // we regain control here
}
echo "\nThe program carries on.";   // runs in every case

And finally? An optional block after the catch, executed no matter what: success, caught exception, or even rethrown exception. It's the place for the cleanup you never want to forget: closing a file, releasing a database connection.

Throw and catch: run it

An account refusing an overdraft, no longer with a mute false (lesson 2), but with an exception that explains the problem. Note: the program carries on after the catch.

<?php
class Compte {
    private float $solde = 0;

    public function deposer(float $m): void { $this->solde += $m; }

    public function retirer(float $m): void {
        if ($m > $this->solde) {
            throw new RuntimeException(
                "Insufficient balance: short by " . ($m - $this->solde) . " €"
            );
        }
        $this->solde -= $m;
    }
    public function getSolde(): float { return $this->solde; }
}

$c = new Compte();
$c->deposer(50);

try {
    $c->retirer(200);                 // throws the exception
    echo "Withdrawal done\n";        // skipped
} catch (RuntimeException $e) {
    echo "Error: " . $e->getMessage() . "\n";
} finally {
    echo "Balance: " . $c->getSolde() . " €\n";   // always runs
}

echo "The program carries on normally.\n";
?>

The hierarchy: from specific to general

Every exception descends from a single base class: Exception. PHP then provides a ready-made family, the SPL exceptions, that inherit from it:

  • InvalidArgumentException: a received argument is invalid (negative price, wrong format).
  • RuntimeException: a problem occurring at runtime (balance too low, resource unavailable).
  • LogicException: a logic error in the program, one you should have avoided while coding.

And this is where the inheritance from lesson 3 pays off. A catch catches a class and all its child classes: that's polymorphism. Since InvalidArgumentException inherits (indirectly) from Exception, a catch (Exception $e) also catches InvalidArgumentException.

Direct consequence: you catch from the most specific to the most general. A too-broad catch placed first would grab everything, and the more precise catch blocks below would never run.

<?php
function valider(int $age): void {
    if ($age < 0)   throw new InvalidArgumentException("Negative age");
    if ($age > 150) throw new RuntimeException("Unrealistic age");
}

foreach ([-5, 200, 30] as $age) {
    try {
        valider($age);
        echo "$age: valid\n";
    } catch (InvalidArgumentException $e) {     // most specific first
        echo "$age: invalid argument (" . $e->getMessage() . ")\n";
    } catch (Exception $e) {                    // the safety net, last
        echo "$age: other error (" . $e->getMessage() . ")\n";
    }
}
?>
⚠️ Warning: if you put catch (Exception $e) before catch (InvalidArgumentException $e), the first catches everything (inheritance kicks in) and the second becomes dead code. Always from specific to general.

Your own exceptions: domain meaning in the name

Reusing an SPL exception is often enough. But sometimes the name of the error should speak your domain's language. CommandeIntrouvableException tells the business problem far better than a generic RuntimeException. And creating your own is trivial: just extend Exception (inheritance from lesson 3 again).

<?php
// A business exception: its name already says it all
class CommandeIntrouvableException extends Exception {}

class Catalogue {
    private array $commandes = ["CMD-1" => 49.90, "CMD-2" => 12.00];

    public function montant(string $ref): float {
        if (!isset($this->commandes[$ref])) {
            throw new CommandeIntrouvableException("Order $ref not found");
        }
        return $this->commandes[$ref];
    }
}

$cat = new Catalogue();
foreach (["CMD-1", "CMD-9"] as $ref) {
    try {
        echo "$ref: " . $cat->montant($ref) . " €\n";
    } catch (CommandeIntrouvableException $e) {   // we target this case precisely
        echo "Business problem: " . $e->getMessage() . "\n";
    }
}
?>

The decision rule: reuse an SPL as long as it describes the problem well (invalid argument, runtime state). Create your own as soon as you want to catch it in a targeted way, or when its name carries business information the caller must be able to distinguish.

✦ Best practice: an exception for the exceptional, not for the normal flow. "The user entered a password that's too short" is an expected case: validate it with an if and an on-screen message. "The database is unreachable" is abnormal: there, an exception is legitimate. Throwing an exception to drive an expected flow makes the code unreadable and costs performance.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Tu demandes à l'IA de « gérer l'erreur » d'un retrait. Elle te rend ceci. Ton rôle de relecteur : l'accepter tel quel ou le rejeter, et dire pourquoi.

try {
    $compte->retirer(200);
} catch (Exception $e) {
    // rien : on ignore, ça repartira
}
Rejeter. C'est le piège du catch vide : l'exception est attrapée puis jetée à la poubelle. Le retrait a échoué, mais le programme continue comme si tout allait bien : c'est le bug fantôme, invisible jusqu'au jour où un solde incohérent fait des dégâts. Une exception qu'on attrape doit être traitée (message à l'utilisateur, log, valeur de repli) ou relancée. Ne jamais l'avaler en silence. Bonus : catch (Exception) est trop large ici ; viser RuntimeException serait plus précis.
🧠 Rappel libre
Rappel libre

Sans remonter dans la leçon : pourquoi faut-il placer catch (InvalidArgumentException) AVANT catch (Exception), et quel lien cela a-t-il avec l'héritage de la leçon 3 ?

Un catch attrape une classe et toutes ses classes filles (polymorphisme). Comme InvalidArgumentException hérite d'Exception, un catch (Exception) attrape aussi les InvalidArgumentException. Si on le place en premier, il rafle tout et le catch spécifique en dessous devient du code mort. On va donc du plus spécifique au plus général : le filet large en dernier.
🔧 Débugue le code

Symptôme : ce code devrait afficher le résultat de la division, puis Fin du programme. Mais il s'arrête net sur Uncaught InvalidArgumentException: Division par zéro interdite et Fin du programme ne s'affiche jamais. Répare-le dans l'éditeur, puis exécute.

<?php
function diviser(int $a, int $b): float {
    if ($b === 0) {
        throw new InvalidArgumentException("Division par zéro interdite");
    }
    return $a / $b;
}

echo diviser(10, 0) . "\n";
echo "Fin du programme\n";
La cause : diviser(10, 0) lève une exception que personne n'attrape. Une exception non gérée devient une erreur fatale et stoppe le script avant la ligne suivante. La correction : entourer l'appel risqué d'un try et gérer le problème dans un catch : try { echo diviser(10, 0); } catch (InvalidArgumentException $e) { echo "Erreur : " . $e->getMessage(); }. Après le bloc, l'exécution reprend et Fin du programme s'affiche enfin.
Que se passe-t-il quand un throw est exécuté dans une méthode ?
Pourquoi lever une exception plutôt que retourner false ou -1 en cas d'échec ?
On écrit catch (Exception $e) AVANT catch (InvalidArgumentException $e). Que se passe-t-il quand une InvalidArgumentException est levée ?
Tu écris class CommandeIntrouvableException extends Exception {}, tu la lèves dans une méthode, mais l'appelant fait catch (Exception $e) { /* vide */ }. Quels sont les deux problèmes ?
Prochaine étape

Tes classes savent maintenant signaler proprement leurs échecs. Reste à les organiser dans un vrai projet : éviter les collisions de noms et les charger automatiquement. C'est le rôle des namespaces.

Leçon 7 : Namespaces →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement