levelmax World 6 Errors & Edges
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 6.1 ~12 min Target: PHPStan max

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:

  • LogicException means 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.
  • RuntimeException means 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

Before
<?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.';
}
After
<?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.";
}

Quick check

1. The basket page needs to know how many mugs are left when an add fails. Where should it get that number?
2. A customer's coupon expired. Which built-in exception should CouponExpired extend?
3. Why give OutOfStock a private constructor and a static forSku()?

CHALLENGE

The basket page used to show "only 3 left" by checking whether the exception's message contained only. Then someone reworded the message, and customers started seeing "Something went wrong" instead. BasketPage (read only) has been rewritten to catch exceptions by type and read what it needs from their properties.

UnknownCoupon is done. Write OutOfStock and CouponExpired the same way, and make Basket throw them instead of a bare Exception. The messages are for the log, so they should still say what happened, but nothing reads them any more.

REFERENCE
private function __construct(public readonly string $coupon, string $message)
{
    parent::__construct($message);
}

An exception class can't be readonly, because Exception's own properties aren't. Make each property readonly instead, and don't reuse a name Exception already has: $message, $code, $file, $line or $previous.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.