levelmax World 3 PHPStan to Max
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 3.4 ~12 min Target: PHPStan max

Strict rules and bleeding edge

max is the top of PHPStan's levels, not the end of what it can check. The levels are about types: is this value what the code says it is? Plenty of bugs involve values that are exactly the type you declared and still do the wrong thing, because PHP was being helpful.

if (!$code) { ... }                  // "0" is falsy, so coupon "0" counts as no coupon
in_array('1000', ['1e3']);           // true: two numeric strings compare as numbers

Both lines are type-correct, so max is happy with them. Both are bugs waiting for the right input.

phpstan-strict-rules

phpstan/phpstan-strict-rules is the PHPStan team's set of extra rules for exactly this kind of code. Among other things it reports:

Rule Instead, write
Non-booleans in if, while, ! and && $code === null, $items !== []
in_array(), array_search() and array_keys() without strict mode in_array($code, $codes, true)
empty() === null, === '', === [] or count(): say which empty you mean
Loose == and != === and !==
Variable variables, dynamic calls to static methods Ordinary code
composer require --dev phpstan/phpstan-strict-rules
includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon

Some of it is opinion. empty($coupon) on an object is fine at runtime, and strict rules will still ask you to write $coupon === null. That's a fair trade: empty() means six different things depending on the type, and the reader shouldn't have to work out which. If one rule really doesn't suit a codebase, switch that rule off rather than dropping the whole set:

parameters:
    strictRules:
        disallowedEmpty: false

Bleeding edge

Each PHPStan release adds checks that are too disruptive to switch on in a minor version, so they wait for the next major. Bleeding edge turns them on now:

includes:
    - vendor/phpstan/phpstan/conf/bleedingEdge.neon

You get the next major version's checks early, and upgrading becomes a non-event. The cost is that a minor PHPStan update can bring new errors. Use it on projects already clean at max, and expect a few new errors when you update PHPStan.

Level max"PHPStan has nothing left to say"
Level max + strict rules"Why does coupon 0 take nothing off?"
Level max + strict rules + bleeding edge"I have seen the future and it has a line number"

Power-up

Before
<?php

final class Newsletter
{
    public function subscribe(?string $email, array $blocked): void
    {
        if (!$email || in_array($email, $blocked)) {
            return;
        }
        if (empty($this->subscribers->find($email))) {
            $this->subscribers->add($email);
        }
    }
}
After
<?php

final class Newsletter
{
    /** @param list<string> $blocked */
    public function subscribe(?string $email, array $blocked): void
    {
        if ($email === null || $email === '' || in_array($email, $blocked, true)) {
            return;
        }
        if ($this->subscribers->find($email) === null) {
            $this->subscribers->add($email);
        }
    }
}

Quick check

1. in_array('1000', ['1e3']) returns:
2. Your project is clean at max. What does adding phpstan-strict-rules check that max didn't?
3. One strict rule doesn't suit your codebase. What should you do?

CHALLENGE

CouponCheck is clean at level max, and this project now runs strict rules and bleeding edge as well. Customers with the staff code 0 get no discount, and the thousandth-customer code 1000 is rejected as expired.

Fix every strict rules error in src/CouponCheck.php without ignoring any of them. The tests cover both bugs.

Running on PHP 8.3…

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