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