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

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

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

Quick check

1. With missingCheckedExceptionInThrows on, a method calls something that declares @throws RefundRejected. What must the method do?
2. Why make RuntimeException checked but leave LogicException unchecked?
3. PHPStan says RefundController::refund() is missing @throws RefundRejected. The controller returns responses. What should you do?

CHALLENGE

When a card has been cancelled, the issuer rejects the refund, and the customer service screen shows a 500. Nothing on the way up admits that a refund can be rejected: PaymentGateway (read only) says so in its @throws, Refunds claims it throws something else, and RefundController doesn't know.

This project now has PHPStan check exceptions: every RuntimeException a method can throw has to be in its @throws or caught. Click Analyse and follow RefundRejected up until something handles it. The controller should answer a rejection with a 422 saying The card issuer won't take the refund back (card_cancelled). Refund order R-1001 another way., with the issuer's reason and the order's reference.

REFERENCE
/**
 * @throws RefundRejected when the card issuer won't take it back
 */

A method can list several exceptions, one @throws line each, or as a union: @throws RefundRejected|GatewayUnavailable.

Running on PHP 8.3…

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