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.
<?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.