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
<?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']); } }
<?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'); } } }