Custom Exceptions

Custom Exception Classes and Exception Chaining

Give your application one base exception, so catch (ShopException $e) covers all of your own, and a subclass for each decision a caller must make, carrying the data it needs. When you wrap a low-level exception, pass it as previous: this chaining keeps the root cause for the log.

An OutOfStockException carrying data, and a ShopException chaining a JsonExceptionPHP
<?php
class ShopException extends RuntimeException {}
final class OutOfStockException extends ShopException {
    public function __construct(public readonly string $sku, public readonly int $available) {
        parent::__construct("$sku: only $available left", 409);
    }
}
function stock(string $json): int {
    try { return json_decode($json, true, flags: JSON_THROW_ON_ERROR)['BK-101']; }
    catch (JsonException $e) { throw new ShopException('bad stock.json', previous: $e); }
}
try { throw new OutOfStockException('BK-101', stock('{"BK-101": 3}')); }
catch (OutOfStockException $e) { echo $e->getMessage(), ", offer {$e->available}\n"; }
try { stock('{"BK-101": 3,}'); }
catch (ShopException $e) {
    $cause = $e->getPrevious();
    echo "{$e->getMessage()} <- ", $cause::class, ": {$cause->getMessage()}\n";
}
Output
BK-101: only 3 left, offer 3
bad stock.json <- JsonException: Syntax error

The checkout can offer the three copies left because the exception carries available. Name classes after what happened, and put a specific catch before a general one.