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

Writing a custom rule

Every team has rules that live in a wiki page and a few reviewers' heads. Ask the Clock for the time, never new DateTimeImmutable(), or the code can't be tested on a fixed date. Reviewers catch it most of the time. A PHPStan rule catches it every time, with a file and a line number, before anyone has to read the pull request.

Anatomy of a rule

PHPStan parses every file into an abstract syntax tree (AST) using nikic/php-parser, then hands each node to the rules that asked for that type of node:

/** @implements Rule<New_> */
final class NoNewDateTimeRule implements Rule
{
    public function getNodeType(): string
    {
        return New_::class;               // called for every `new ...` expression
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // $node is a New_ here, thanks to @implements Rule<New_>
        return [
            RuleErrorBuilder::message('Ask the Clock for the time instead of calling new DateTimeImmutable().')
                ->identifier('clock.now')
                ->build(),
        ];
    }
}

Return an empty array when the node is fine, and one error for each problem when it isn't. PHPStan 2 requires an identifier on every error, the same kind you've been ignoring precisely since 3.1.

To find out which node type you need and what's inside it, dump the tree for a small example:

vendor/bin/php-parse '<?php new DateTimeImmutable("now");'
0: Stmt_Expression(
    expr: Expr_New(
        class: Name(
            name: DateTimeImmutable
        )
        args: array(
            0: Arg(
                value: Scalar_String(
                    value: now
                )
            )
        )
    )
)

Expr_New is PhpParser\Node\Expr\New_. Its class is a Name for new Foo(), but an expression for new $class() and a class node for new class {}, so check with instanceof before you use it.

Scope knows where you are

The Scope is PHPStan's knowledge at that point in the code. The parts a rule usually needs:

Call Gives you
$scope->resolveName($name) The fully qualified class name, with use statements applied
$scope->getType($expr) The type of any expression, as PHPStan understands it
$scope->getClassReflection() The class you're inside, or null, with implementsInterface(), isSubclassOf() and so on
$scope->getFunction() The method or function you're inside

A rule that ignores context is a rule people learn to ignore. SystemClock has to call new DateTimeImmutable(), because that's what a clock is for, so the rule should stay quiet inside any class that implements Clock.

Register it, test it

A rule is switched on in phpstan.neon:

rules:
    - App\PHPStan\NoNewDateTimeRule

And it's tested like any other code, with PHPStan's RuleTestCase. You give it a fixture file and the errors you expect, as message and line:

$this->analyse([__DIR__ . '/data/dates.php'], [
    ['Ask the Clock for the time instead of calling new DateTimeImmutable().', 12],
]);

The fixture is the rule's spec. Put in every case it should catch, and every case it shouldn't, and the test fails if the rule reports anything else.

Power-up

Before
<?php

// CONTRIBUTING.md, section 7 of 12:
//
//   "Please don't call new DateTimeImmutable() in application code.
//    Inject the Clock instead, so tests can fix the date."
//
// Reviewers remember most of the time.
final class TrialExpiry
{
    public function hasEnded(Trial $trial): bool
    {
        return $trial->endsAt < new DateTimeImmutable();
    }
}
After
<?php

// phpstan.neon:
//
//   rules:
//       - App\PHPStan\NoNewDateTimeRule
//
// src/Trial/TrialExpiry.php:14  Ask the Clock for the time instead of calling new DateTimeImmutable().
//                               🪪 clock.now
final class TrialExpiry
{
    public function __construct(private Clock $clock) {}

    public function hasEnded(Trial $trial): bool
    {
        return $trial->endsAt < $this->clock->now();
    }
}

Quick check

1. Your rule's getNodeType() returns New_::class. For which code is $node->class not a Name?
2. Inside processNode(), how do you find out whether the code being analysed is inside a class that implements Clock?
3. How does RuleTestCase decide whether a rule is right?

CHALLENGE

The team's rule is "ask the Clock for the time", and phpstan.neon already registers a rule for it. The rule doesn't report anything yet.

Make NoNewDateTimeRule report new DateTime() and new DateTimeImmutable() that mean "now", except inside a class that implements App\Clock. Then fix what it finds. The tests and the fixture are the rule's spec, and the rule itself has to be clean at max.

REFERENCE
use PhpParser\Node\Name;             // new Foo(): $node->class is a Name
use PhpParser\Node\Scalar\String_;   // 'now': $arg->value->value is the string

$node->getArgs();                    // list<Arg>, each with ->value
$scope->resolveName($node->class);   // 'DateTimeImmutable', with use statements applied
$scope->getClassReflection()?->implementsInterface(Clock::class);

RuleErrorBuilder::message('...')->identifier('clock.now')->build();

The message is Ask the Clock for the time instead of calling new DateTimeImmutable()., with DateTime in place of DateTimeImmutable when that's the class.

Running on PHP 8.3…

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