Introduction

I think that everyone these days agrees that typecasting is iffy (to say the least) and that relying on typecasting can be a major source of bugs, which sometimes are very hard to debug. In PHP a solution to this problem is placing a declare(strict_types=1) statement right after the classic <?php.

Recently while navigating older codebases I noticed that some files did not include such statement. The first thing I thought was looking up a rule in PHPStan to enforce this on the whole codebase, but after some Googling I couldn't find a ready to use solution. So after browsing PHPStan documentation for a few minutes I discovered Custom Rules, which as the name suggests are rules you write yourself by implementing an interface provided by PHPStan. You can read more about it on this link.

Where we're headed:

  • Our example class:
<?php

namespace App;

class Mouth
{
    public function shout(string $phrase): void
    {
        echo $phrase;
    }
}
  • The output from running the PHPStan binary:
php ./vendor/bin/phpstan
Note: Using configuration file /home/davi/dev/strict-types/phpstan.neon.
 4/4 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ---------------------------------------------------
  Line   src/Mouth.php
 ------ ---------------------------------------------------
  3      File is missing declare(strict_types=1) statement
 ------ ---------------------------------------------------

 [ERROR] Found 1 error
  • An IDE pinpointing the error:

Implementing our custom rule:

This is the interface defined by PHPStan which we need to implement:

/**
 * @api
 * @phpstan-template TNodeType of Node
 */
interface Rule
{
    /**
     * @phpstan-return class-string<TNodeType>
     */
    public function getNodeType(): string;

    /**
     * @phpstan-param TNodeType $node
     * @return (string|RuleError)[] errors
     */
    public function processNode(Node $node, Scope $scope): array;
}

The getNodeType method tells on which nodes our processNode method will be called. Our processNode method should return an array of RuleError objects or an empty array in case everything is ok. Since we are dealing with files our TNodeType should be the PHPStan\Node\FileNode class, that way our rule will be called on every file, we should also update our generic Docblock, that way PHPStan won't complain about our rule class itself.

Here's how the implementation looks like so far:

use PHPStan\Node\FileNode;

/**
 * @implements Rule<FileNode>
 */
class EnforceStrictTypes implements Rule
{
    public function getNodeType(): string
    {
        return FileNode::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        throw new \Exception('To be implemented');
    }
}

Now on to the processNode implementation. We're gonna receive two objects: Node and Scope, we are only interested in the first one. Our Node object is an instance of what we defined on the getNodeType method, so in this case a FileNode.

Since we are dealing with a file our node will consist of a bunch of sub-nodes. Luckily we are looking for exactly the first node, because declare statements must be put at the start of files. I decided that empty files should not be checked, here's how our code looks so far:

public function processNode(Node $node, Scope $scope): array
{
    $nodes = $node->getNodes();

    if (empty($nodes)) {
        return [];
    }

    $firstStatement = $nodes[0] ?? null;
    // check if $firstStatement is a declare block
}

Now it's basically done, all we have to do is check if the $firstStatement variable is defined and is an instance of PhpParser\Node\Stmt\Declare_.

$firstStatement = $nodes[0];
$fileDeclaresStrictTypes = $firstStatement instanceof Declare_;

if (!$fileDeclaresStrictTypes) {
    return [RuleErrorBuilder::message(
        'File is missing declare(strict_types=1) statement'
    )->build()];
}

We also need to check if strict_types is set to 1. AFAIK PhpParser generates some code during runtime while it parses the code, that caused PHPStan to not recognize some properties of the object, hence the @phpstan-ignore-next-line. Anyway, here's how it looks:

/* @phpstan-ignore-next-line */
$strictTypesSetAsOne = $firstStatement->declares[0]->value->value === 1;

if (!$strictTypesSetAsOne) {
    return [RuleErrorBuilder::message(
        'declare(strict_types=1) statement value must be set as 1'
    )->build()];
}

And here's the final code:

/**
 * @implements Rule<FileNode>
 */
class EnforceStrictTypes implements Rule
{
    public function getNodeType(): string
    {
        return FileNode::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        $nodes = $node->getNodes();

        if (empty($nodes)) {
            return [];
        }

        $firstStatement = $nodes[0];
        $fileDeclaresStrictTypes = $firstStatement instanceof Declare_;

        if (!$fileDeclaresStrictTypes) {
            return [RuleErrorBuilder::message(
                'File is missing declare(strict_types=1) statement'
            )->build()];
        }

        /* @phpstan-ignore-next-line */
        $strictTypesSetAsOne = $firstStatement->declares[0]->value->value === 1;

        if (!$strictTypesSetAsOne) {
            return [RuleErrorBuilder::message(
                'declare(strict_types=1) statement value must be set as 1'
            )->build()];
        }

        return [];
    }
}

Right now everything should be working and when you analyse the code it will complain about files not declaring strict types.

Testing our custom rule

Writing a custom rule is not enough, we should also unit test it to make sure the rule itself will do what it's suppose to do. PHPStan paired with PHPUnit gives us a very easy to use API to test custom rules.

To test our rule we need to create a PHPUnit test and extend the PHPStan\Testing\RuleTestCase class. We also need to create fixture files that will be used as examples for testing our rule. I created 5 examples (the names are pretty self-explanatory):

  • ClassWithStrictTypes.php
  • ClassWithoutStrictTypes.php
  • ClassWithStrictTypesSetAsFalse.php
  • empty-file.php
  • file-without-strict-types.php

The tests I wrote:

/**
 * @extends RuleTestCase<EnforceStrictTypes>
 */
class EnforceStrictTypesTest extends RuleTestCase
{
    protected function getRule(): Rule
    {
        return new EnforceStrictTypes();
    }

    #[Test]
    public function itEnforcesStrictTypesOnAllFiles(): void
    {
        $fixtures = [
            __DIR__ . '/../../Data/ClassWithoutStrictTypes.php',
            __DIR__ . '/../../Data/file-without-strict-types.php',
            __DIR__ . '/../../Data/ClassWithStrictTypes.php', // this one is ok
        ];

        $this->analyse($fixtures, [
            ['File is missing declare(strict_types=1) statement', 3],
            ['File is missing declare(strict_types=1) statement', 3],
        ]);
    }

    #[Test]
    public function itChecksIfStrictTypesIsSetToAValueDifferentThanOne(): void
    {
        $fixtures = [__DIR__ . '/../../Data/ClassWithStrictTypesSetAsFalse.php'];

        $this->analyse($fixtures, [['declare(strict_types=1) statement value must be set as 1', 3]]);
    }

    #[Test]
    public function itDoesNotCheckEmptyFiles(): void
    {
        $fixtures = [__DIR__ . '/../../Data/empty-file.php'];

        $this->analyse($fixtures, []);
    }
}

The analyse method second parameter is an array of errors that should return, it should contain the message and the line where the error occurred.

Bonus

PHPStan will also complain about our fixture files, to ignore them set this to your phpstan.neon config file.

parameters:
    level: 8
    paths:
        - src
        - tests
    excludePaths:
        - */tests/Data/*.php
rules:
    - App\Rules\EnforceStrictTypes