Failure in the signature
Customer service clicks Refund on an order paid with a card that's since been cancelled. The issuer rejects the refund, and the screen shows a 500.
The gateway's interface says, in its docblock, that refund() can throw RefundRejected. The service that calls it doesn't mention that, and its docblock still says it throws OrderNotFound, from before it was given the order instead of looking it up. The controller that calls the service has no idea anything can go wrong. Every type in the chain is right, and the one fact that mattered got lost on the way up.
A return type for the sad path
A signature tells you what a method gives back when it works. @throws tells you how it can fail, and it's just as much a part of the contract:
/** * @throws RefundRejected when the card issuer won't take it back */ public function refundInFull(Order $order): void
On its own that's a comment, and comments go stale, as @throws OrderNotFound did. PHPStan can check it, though, the same way it checks return types. Turn on its exception rules:
parameters: exceptions: checkedExceptionClasses: - RuntimeException check: missingCheckedExceptionInThrows: true tooWideThrowType: true
Now a method that can throw a checked exception, because it throws one or calls something whose @throws lists one, must either catch it or declare it. And a @throws that can't happen any more is an error too. PHPStan follows the exception up the call chain, one method at a time, until something handles it.
Which exceptions are checked?
If every exception were checked, every method would end up declaring TypeError and ValueError and nothing would mean anything. The useful line is the one from 6.1:
- Checked: failures a caller should plan for. The card was declined, the coupon expired, the refund was rejected. These are
RuntimeExceptions. - Unchecked: bugs. Nobody should catch a
LogicException, so nobody should have to declare one.
checkedExceptionClasses: [RuntimeException] says exactly that. If your code throws RuntimeException for things that really are bugs, you'll find out quickly, and the fix is usually a LogicException.
Handle it where you can answer it
Declaring an exception doesn't handle it. It passes the decision up. Keep passing it up until you reach code that knows what to do, which is usually where there's someone to tell: a controller with a response, a command with an exit code, a job with a retry. For a refund that's the controller, so the controller catches it, and its @throws stays empty.
Power-up
<?php final readonly class Refunds { /** @throws OrderNotFound */ // stale: it's given the order now public function refundInFull(Order $order): void { $this->payments->refund($order->chargeId, $order->pence); // @throws RefundRejected } } // RefundController: nothing here knows a refund can fail, so it's a 500 $this->refunds->refundInFull($order);
<?php final readonly class Refunds { /** @throws RefundRejected */ // and PHPStan checks it's true public function refundInFull(Order $order): void { $this->payments->refund($order->chargeId, $order->pence); } } // RefundController: where there's someone to tell try { $this->refunds->refundInFull($order); } catch (RefundRejected $e) { return new Response(422, "The card issuer won't take the refund back ({$e->reason}). …"); }