1 Creating Custom Rules for PHPStan
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.
Introduction
As mentioned in the introduction, we’re going to use PHPStanin this book to verify that after decoupling our code it will remain decoupled forever. In order to do so we first need to install PHPStan, and configure it to analyze our project files, then practice a bit with writing custom rules. I recommend you to follow along with this chapter, implementing the examples yourself inside one of your projects.
Analyzing Code with PHPStan
PHPStan is a tool that statically analyzes your PHP code. This means it doesn’t run your code, but only “reads” it. It then performs a number of checks on your code, which are called “rules”. For instance, when your code calls some method, it checks that you supply the right number of arguments, and that the arguments match the expected types. If it finds any issues, PHPStan will report this as output in your terminal.
You’ll find all the details about installing and configuring PHPStan on its website, but the basic steps are:
- Install PHPStan as a Composer package in your project:
composer require --dev phpstan/phpstan - Create a
phpstan.neonconfiguration file in the root of your project - Run PHPStan:
vendor/bin/phpstan
A basic phpstan.neon configuration file looks like this:
parameters:
level: max
paths:
- src
- tests
I prefer using the max level, so PHPStan can operate at maximum power. For existing code, this may be quite problematic - check out the documentation section about the Baselineto find out how to deal with this.
We need to provide the paths that PHPStan should analyze (in this example src and tests). Every PHP file in the project, except vendor code should be analyzed, or we’ll miss out on valuable feedback.
When we run vendor/bin/phpstan we’ll get output similar to this:
------ ------------------------------------------------------------
Line src/example.php
------ ------------------------------------------------------------
5 Parameter #1 $json of function json_decode expects string,
string|false given.
------ ------------------------------------------------------------
[ERROR] Found 1 error
PHPStan lists all errors, the files in which they occur, and their line number. Every error needs to be fixed, in one way or another. In this example we get an error about the first argument provided to the json_decode() function call. It expects a string, but we pass string|false to it:
<?php
declare(strict_types=1);
json_decode(file_get_contents('some.json'));
Looking at the documentation of file_get_contents(), it turns out that this function returns a string, which is the contents of the requested file, or false if the file doesn’t exist. In our code we need to deal with both cases. One way to do so is to throw an exception in case file_get_contents() returns false:
<?php
declare(strict_types=1);
$filename = 'some.json';
$contents = file_get_contents($filename);
if ($contents === false) {
throw new RuntimeException('Could not read file ' . $filename);
}
json_decode($contents);
PHPStan is smart enough to understand that below the throw statement it can be certain that $contents is no longer possibly false. According to the union type string|false, what remains is that $contents is a string, and it will be safe to pass that to json_decode(). So when we run vendor/bin/phpstan again, it shows no errors:
[OK] No errors
To ensure that fixing PHPStan-reported errors isn’t an optional activity for developers, we have to commit phpstan.neon and add the vendor/bin/phpstan command to the project’s build script.
Catching Specific Node Types
Now that PHPStan is ready to be used in your project, let’s start by creating a custom rule. As a warm-up exercise, let’s report any code that uses the error silencing operator @ to suppress errors raised by PHP functions like file_get_contents(), as is done in this example:
src/error-silencing.php<?php
declare(strict_types=1);
@file_get_contents('not-found.txt');
Instead of silencing errors, we’d like full exposure about everything that goes wrong in our code. We want to fix issues instead of ignoring them. So let’s make a custom PHPStan rule that forbids the use of @ anywhere in the project.
A PHPStan rule is a class that implements the PHPStan\Rules\Rule interface. The class should be auto-loadable. I prefer to keep rule classes outside the normal src folder though, so I have a dedicated line for PHPStan rules in the autoload-dev section of my composer.json file:
{
"require-dev": {
"phpstan/phpstan": "^1.5"
},
"autoload-dev": {
"psr-4": {
"Utils\\PHPStan\\": "utils/PHPStan/src"
}
}
}
Our first rule class is going to be called NoErrorSilencingRule:
utils/PHPStan/src/NoErrorSilencingRule.php<?php
declare(strict_types=1);
namespace Utils\PHPStan;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
final class NoErrorSilencingRule implements Rule
{
public function getNodeType(): string
{
return Node::class;
}
public function processNode(Node $node, Scope $scope): array
{
return [];
}
}
For now, I’ve only added a minimal implementation of the interface. It doesn’t do anything useful yet, but it also doesn’t break anything. To make PHPStan aware of the new rule class, we should add it to phpstan.neon:
parameters:
level: max
paths:
- src
rules:
- Utils\PHPStan\NoErrorSilencingRule
Run PHPStan to check that it can find the new rule. If it couldn’t find the rule class, it will show an error.
We’ve set up a new rule class, now it’s time to implement it. Note that the rule has two methods: getNodeType() and processNode(). These work in a similar fashion as your average event dispatcher does: you register the type of event you’re interested in, and you will be notified when such an event occurs. For a rule you register the “node type” you’re interested in, and when PHPStan encounters a node of that type, it will call processNode(). But what’s a node?
Nodes are elements of the code that can be recognized as meaningful units. For example, a class is represented as a single node, and all its properties and methods are also nodes. Within every class method, each statement is recognized as a node, and also each expression. Nodes can contain other nodes, or lists of nodes, and so on. Each PHP file can be interpreted as one big tree of nodes.
PHP itself builds such a tree of nodes when it loads one of your .php files and runs it. It first needs to understand what’s inside the file and if it makes any sense. In order to do so it parses the .php code, and as a results makes a node tree of it, which it can then turn into so-called byte code, which is executable by PHP’s virtual machine. Instead of running code based on the node tree, which is called Abstract Syntax Tree (AST), we could also analyze the tree and spot potential errors, as PHPStan does.
PHPStan uses the PHP-Parser libraryto parse your project’s PHP code and create an AST for each file. It then walks over the tree of nodes, asking each rule: are you interested in this node (getNodeType())? If so, here you have it (processNode()); please let me know if you want to report any errors about it!
To decide if the rule should trigger an error, the rule usually has to take a closer look at the node. Nodes themselves are simple PHP objects. The classes of those objects can be found in the PHP-Parser library. When you install PHPStan as a Composer package, all of its code is inside a single .phar file (vendor/phpstan/phpstan/phpstan.phar). This file also contains PHPStan’s vendor dependencies, including the PHP-Parser library. If you use an IDE like PhpStorm you can inspect the contents of the .phar file and find this library. Look for vendor/nikic/php-parser/lib/PhpParser/Node and you’ll find every possible node class.
When you’re creating a new rule, you’ll have to decide what type of node you want to target. Because we don’t know much at this point, we pick the most general node type - the PhpParser\Node interface that all node classes implement, and return its name in getNodeType(). Since every node that PHPStan encounters implements this interface, it will call processNode() for every node in every PHP file we analyze. We can use this to gain some understanding about what kind of nodes we should be looking for. Let’s print the type (i.e. class) of each node on screen for now:
<?php
declare(strict_types=1);
namespace Utils\PHPStan;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
final class NoErrorSilencingRule implements Rule
{
public function getNodeType(): string
{
return Node::class;
}
public function processNode(Node $node, Scope $scope): array
{
echo $node::class . "\n";
return [];
}
}
With this rule enabled, we shouldn’t run PHPStan on our entire project, since that would give us too much output. Instead, we should analyze just a single sample file that uses a silencing error, src/error-silencing.php, which contains just the following code:
src/error-silencing.php<?php
declare(strict_types=1);
@file_get_contents('not-found.txt');
To allow direct output via echo we have to add the --debug option when running PHPStan. The full command is:
vendor/bin/phpstan analyze --debug src/error-silencing.php
The output:
src/error-silencing.php
PHPStan\Node\FileNode
PhpParser\Node\Stmt\Declare_
PhpParser\Node\Stmt\DeclareDeclare
PhpParser\Node\Scalar\LNumber
PhpParser\Node\Stmt\Expression
PhpParser\Node\Expr\ErrorSuppress
PhpParser\Node\Expr\FuncCall
PhpParser\Node\Arg
PhpParser\Node\Scalar\String_
--- consumed 6 MB, total 62 MB, took 0.09 s
[OK] No errors
The result is a list of the types of nodes that were found in our error-silencing.php script. Again, each type is actually a class, and you can open each of them in your IDE to find out more about their internal structure. Comparing the list of node types to the code in error-silencing.php we can relate every node to a piece of code. E.g. we see at the end of the list a FuncCall with a single Arg which contains a String_ node. These are all the nodes for the expression file_get_contents('not-found.txt').
Right before the FuncCall node we notice the ErrorSuppress node. That’s the one we’re looking for, it’s the @ in front of the function call. PHPStan should trigger an error for any ErrorSupress node that PHPStan finds in our code. So we should change the getNodeType() function of our rule class to return the ErrorSupress node class instead:
use PhpParser\Node\Expr\ErrorSuppress;
final class NoErrorSilencingRule implements Rule
{
public function getNodeType(): string
{
return ErrorSuppress::class;
}
// ...
}
Next, we need to report an error for the line where we encountered an ErrorSuppress node. PHPStan offers a convenient error builder for this. We only have to provide an error message, and PHPStan will add the remaining information:
use PHPStan\Rules\RuleErrorBuilder;
final class NoErrorSilencingRule implements Rule
{
// ...
public function processNode(Node $node, Scope $scope): array
{
return [
RuleErrorBuilder::message(
'You should not use the silencing operator (@)'
)->build(),
];
}
}
Running PHPStan again, we should now see that it reports an error for our error-silencing.php file:
------ -----------------------------------------------
Line error-silencing.php
------ -----------------------------------------------
5 You should not use the silencing operator (@)
------ -----------------------------------------------
[ERROR] Found 1 error
Great, we can now run PHPStan on the entire project and find all the places that still use the error silencing operator! PHPStan sees everything, and will keep watching forever. Whenever someone makes the “mistake” to use @ again, PHPStan will warn them about it.
Adding Automated Tests for a PHPStan Rule
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.
Deriving Types from the Current Scope
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.
Putting a Node in Context
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.
Generalizing a Rule
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.
Conclusion
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/recipes-for-decoupling.