levelmax World 2 Honest Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 2.6 ~15 min Target: PHPStan level 8

Narrowing and assertions

Level 8 makes null count. find() returns ?Order, and from now on every line that uses the result has to deal with the null:

Cannot call method markPaid() on Order|null.

There are two ways to make that error go away. You can prove the order exists, with a check PHPStan can follow. Or you can claim it does, with a docblock PHPStan will believe. Only one of them is still true when the payment provider sends an order ID you've never heard of.

Narrowing: evidence PHPStan follows

PHPStan narrows a type after any check that rules something out, and keeps the narrower type for the rest of the code path:

Check What PHPStan learns
if ($order === null) { throw ...; } After it, $order is an Order
$order = $orders->find($id) ?? throw new ...; The same, in one line
$order?->customer?->email Fine when null is an acceptable answer
is_string(), is_int(), is_array(), instanceof The type you tested for
isset($data['sku']), array_key_exists() That the key exists

Most of these you write anyway. Throwing early, rather than wrapping the rest of the method in an if, keeps the happy path flat, and PHPStan follows it.

@var is a claim, not evidence

/** @var Order $order */
$order = $this->orders->find($id);

An inline @var tells PHPStan what a variable is, and PHPStan believes it. It doesn't check anything, PHP ignores it completely, and from that line on every error about $order is hidden. If find() returns null, you'll find out from production.

@phpstan-assert: give a check a name

When the checks are long, or needed in several places, move them into a function and tell PHPStan what it proves when it returns:

/** @phpstan-assert array{sku: non-empty-string, price: int<0, max>} $data */
private static function assertPriceUpdate(mixed $data): void
{
    if (!is_array($data) || !is_string($data['sku'] ?? null) || $data['sku'] === ''
        || !is_int($data['price'] ?? null) || $data['price'] < 0) {
        throw new UnexpectedValueException('Not a price update');
    }
}

After self::assertPriceUpdate($data);, $data is that shape. For methods that return a bool, @phpstan-assert-if-true narrows only when the answer is true: @phpstan-assert-if-true !null $this->customer on hasCustomer() lets callers write if ($order->hasCustomer()) and use $order->customer straight away.

PHPStan trusts an assertion tag the way it trusts @var. The difference is that the function does the checking at runtime, so the claim is only as good as the function. Test it.

Power-up

Before
<?php

final class ImportPrices
{
    public function line(string $json): void
    {
        /** @var array{sku: string, price: int} $data */
        $data = json_decode($json, true);

        /** @var Product $product */
        $product = $this->products->findBySku($data['sku']);

        $product->reprice($data['price']);
    }
}
After
<?php

final class ImportPrices
{
    public function line(string $json): void
    {
        $data = json_decode($json, true);
        self::assertPriceUpdate($data);

        $product = $this->products->findBySku($data['sku'])
            ?? throw new DomainException("Unknown SKU {$data['sku']}");

        $product->reprice($data['price']);
    }

    /** @phpstan-assert array{sku: non-empty-string, price: int<0, max>} $data */
    private static function assertPriceUpdate(mixed $data): void
    {
        if (!is_array($data) || !is_string($data['sku'] ?? null) || $data['sku'] === ''
            || !is_int($data['price'] ?? null) || $data['price'] < 0) {
            throw new UnexpectedValueException('Not a price update');
        }
    }
}

Quick check

1. find() returns ?Order. You write /** @var Order $order */ $order = $orders->find(99);, and order 99 doesn't exist. What happens?
2. After if ($order === null) { throw new DomainException('Unknown order'); }, what does PHPStan know about $order?
3. Does PHPStan check that a @phpstan-assert function's body really proves the type it claims?

CHALLENGE

PaymentWebhook handles the payment provider's events. Two @var tags tell PHPStan that the JSON is always well formed and the order always exists, so the only error left at level 8 is the guest checkout. None of those claims is true.

Replace both @var tags with real checks, in src/PaymentWebhook.php:

  • a malformed event throws an UnexpectedValueException before anything else happens
  • an unknown order throws a DomainException saying Unknown order <id>
  • a guest order is still marked paid, without a receipt
Running on PHP 8.3…

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