Exceptions with names
The basket page used to say "Sorry, we've only got 3 left." Then someone tidied up the wording of an exception message, and it started saying "Something went wrong." Nobody touched the page. It was finding out what had happened by searching the message for the word Only, and pulling the number out with a regex.
That's what a caller has to do when every failure is new Exception('...'). The only thing that tells one failure from another is a sentence written for a human, and sentences get reworded.
The type is the API
A caller that wants to react to a failure needs two things: which failure it was, and the facts about it. Give it a class for the first and properties for the second.
final class OutOfStock extends RuntimeException { private function __construct( public readonly string $sku, public readonly int $available, string $message, ) { parent::__construct($message); } public static function forSku(string $sku, int $wanted, int $available): self { return new self($sku, $available, "Wanted {$wanted} of {$sku}, but only {$available} are in stock"); } }
Now the page catches OutOfStock, so nothing else can land in that catch by accident, and reads $e->available instead of parsing it. PHPStan knows that's an int. And the message is free to say whatever helps the person reading the log at 2am, because no code depends on it.
The named constructor, like the ones in 2.2 and 5.1, keeps the wording in one place and reads well where it's used: throw OutOfStock::forSku($sku, $wanted, $available).
Which base class?
PHP's built-in exceptions split into two families, and the split is useful:
LogicExceptionmeans the code is wrong. A caller broke a rule it could have checked: a negative quantity, a method called in the wrong order. The fix is to change the code, so nothing should catch these except the error page.RuntimeExceptionmeans the world is wrong. The code was fine, but this time the stock ran out or the coupon expired. Callers are expected to handle these.
Out of stock is a runtime failure, so OutOfStock extends RuntimeException. 6.3 builds on this split.
When several failures belong together, give them a parent or a shared interface, so a caller that doesn't care which one it was can catch them all. Don't start there, though. Start with one class per thing a caller reacts to differently.
Keep the customer-facing wording out of the exception, too. The page decides what to say, so it can be reworded, translated or made friendlier without touching the basket.
Power-up
<?php // Basket throw new Exception("Only {$available} of {$sku} left"); // BasketPage try { $basket->add($sku, $quantity); } catch (Exception $e) { if (str_contains($e->getMessage(), 'Only')) { // "We've only got…" broke this preg_match('/Only (\d+)/', $e->getMessage(), $m); return "Sorry, we've only got {$m[1]} left."; } return 'Something went wrong.'; }
<?php final class OutOfStock extends RuntimeException { private function __construct(public readonly string $sku, public readonly int $available, string $message) { parent::__construct($message); } public static function forSku(string $sku, int $wanted, int $available): self { return new self($sku, $available, "Wanted {$wanted} of {$sku}, but only {$available} are in stock"); } } try { $basket->add($sku, $quantity); } catch (OutOfStock $e) { return "Sorry, we've only got {$e->available} of {$e->sku} left."; }