Creating custom PHPStan rules for your Symfony project

Published on 2021-10-28 • Modified on 2021-10-28

In this post, we see how to create custom PHPStan rules for a Symfony project. We will check Symfony best practices but also more specific rules. Let's go! 😎

» Published in "A week of Symfony 774" (25-31 October 2021).

Prerequisite

I will assume you have at least a basic knowledge of Symfony and know how to use a static analyser like PHPStan.

Configuration

  • PHP 8.0
  • Symfony 5.4.0-BETA3
  • PHPStan 1.2.0

Introduction

I always have a note on this blog with random post ideas; one of them was "creating a custom PHPStan rule", but I didn't have a precise idea of what to do. On the 20 of October 2021, I took part in the PHP forum in Paris. The talk of FrΓ©dΓ©ric Bouchery was related to this (you can find the slides here πŸ‡«πŸ‡·). He showed us several examples of how to do this. It motivated me to write this post finally. So, thank you, FrΓ©d. 😊

Goal

We see how to create custom PHPStan rules for a Symfony project to check ADR: Architectural Decision Records.

Analysis of a Symfony controller

First, before creating a new custom rule, we must have something to analyse that triggers an error. As we want to analyse a Symfony controller, let's use the maker bundle to generate one; let's call it StanController, run bin/console make:controller and enter Stan. Here is what is generated (don't forget to delete the generated template):

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class StanController extends AbstractController
{
    /**
     * @Route("/stan", name="stan")
     */
    public function index(): Response
    {
        return $this->render('stan/index.html.twig', [
            'controller_name' => 'StanController',
        ]);
    }
}

As you can see, this shiny new controller extends the Symfony AbstractController. But why? If a class extends AbstractController, it is automatically registered as service. Moreover, when using the default configuration (with autoconfigure) it receives the controller.argument_value_resolver tag thanks to this configuration:

# config/services.yaml
    App\Controller\:
        resource: '../src/Controller'
        tags: ['controller.service_arguments']

As we used the maker bundle, it follows the Symfony best practices; let's check out. Indeed, we can find this rule in the best practices reference:

β€œMake your Controller Extend the AbstractController Base Controller”


This is the first rule we will try to implement. Remove extends AbstractController and return new Response(); instead of calling the render() one. The controller is still valid, but it doesn't fulfil the Symfony best practices anymore.

Before creating a new PHPStan rule, let's check that everything is alright:

[15:05:53] coil@Mac-mini.local:/Users/coil/Sites/strangebuzz.com$ ./vendor/bin/phpstan analyse -c configuration/phpstan.neon --memory-limit 1G
 249/249 [β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“] 100%

                                                                                                                        
 [OK] No errors                                                                                                         
                                                                                                                        

Checking a Symfony best practice

Now let's write a new rule that triggers an error if one of our controllers doesn't extend the Symfony default one. We must create a class that extends the PHPStan\Rules\Rule class. We have two functions to implement getNodeType() and processNode(). Let's put this new class in src/PHPStan. Note that as this class is in the src/ directory, PHPStan analyses it too. Here it is:

<?php

declare(strict_types=1);

// src/PHPStan/ControllerExtendsSymfonyRule.php

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use function Symfony\Component\String\u;

/**
 * @implements Rule<Class_>
 */
final class ControllerExtendsSymfonyRule implements Rule
{
    /**
     * Restricts on classes' nodes only. One rule, one check.
     */
    public function getNodeType(): string
    {
        return Class_::class;
    }

    /**
     * @param Class_ $node
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if (!$this->isInControllerNamespace($scope)) {
            return [];
        }

        if (!is_a($node->namespacedName->toString(), AbstractController::class, true)) {
            return [sprintf('Controllers should extend %s.', AbstractController::class)];
        }

        return [];
    }

    /**
     * Check that the class belongs to the controller namespace.
     */
    private function isInControllerNamespace(Scope $scope): bool
    {
        return u($scope->getNamespace())->startsWith('App\Controller');
    }
}

Some explanations: the getNodeType() function allows us to filter the nodes we want to analyse; we want the classes which are represented by the PhpParser\Node\Stmt\Class_ node class. We filter on the current namespace in the processNode() function because we only want to process the classes in the src/Controller folder. Finally, we can do the relevant check; we verify that the current class is a child of Symfony\Bundle\FrameworkBundle\Controller\AbstractController.

We added a new rule. Now we must tell PHPStan to use it. We declare it the phpstan.neon file. You can find my complete configuration in this snippet.

rules:
    - App\PHPStan\ControllerExtendsSymfonyRule

Now, we can run PHPStan and check if the rule works:

 249/249 [β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“] 100%

 ------ ----------------------------------------------------------------------------------------
  Line   src/Controller/StanController.php
 ------ ----------------------------------------------------------------------------------------
  9      Controllers should extend Symfony\Bundle\FrameworkBundle\Controller\AbstractController.
 ------ ----------------------------------------------------------------------------------------


                                                                                                                        
 [ERROR] Found 1 error                                                                                                  
                                                                                                                        

Yes, it works indeed. Restore the extends instruction and check that the error disappears. Let see another example.

Checking an ADR

What is an ADR? It stands for "Architectural Decision Record", it's a rule you decide to apply to your project. These rules can be documented, of course. But how can you make sure they are applied? We can create another custom rule. In fact, a Symfony best practice is just an ADR shared by a bunch of projects and people πŸ™‚.

Imagine we want to do several checks on our controllers. We already saw how we could filter nodes and namespaces. So, let's refactor what we did. We create an abstract rule which is dedicated to the controller classes; here it is:

<?php

declare(strict_types=1);

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function Symfony\Component\String\u;

/**
 * @implements Rule<Class_>
 */
abstract class AbstractControllerRule implements Rule
{
    /**
     * Restricts on class nodes only. One rule, one node and check.
     */
    public function getNodeType(): string
    {
        return Class_::class;
    }

    abstract public function processNode(Node $node, Scope $scope): array;

    protected function isInControllerNamespace(Scope $scope): bool
    {
        return u($scope->getNamespace())->startsWith('App\Controller');
    }
}

Now, we can create a new rule that extends it. Here, we do a simple check and verify if the class is final. Indeed, controllers are not supposed to be extended. And PHPStorm with the excellent Php Inspections (EA Extended) plugin warns me about this:

β€œ[EA] The class needs to be either final (for aggregation) or abstract (for inheritance)”

We have to implement the processNode() function this time:

<?php

declare(strict_types=1);

// src/PHPStan/ControllerIsFinalRule.php

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;

final class ControllerIsFinalRule extends AbstractControllerRule
{
    /**
     * @param Class_ $node
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if (!$this->isInControllerNamespace($scope)) {
            return [];
        }

        // Skip abstract controllers
        if ($node->isAbstract()) {
            return [];
        }

        if (!$node->isFinal()) {
            return ['ADR nΒ°1: A Symfony controller should be final.'];
        }

        return [];
    }
}

The relevant check is quite straighforward, we have to call the isFinal() function on the $node object. We must declare this new rule as we did before. And we can rerun PHPStan:

 249/249 [β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“] 100%

 ------ ------------------------------------------------
  Line   src/Controller/StanController.php
 ------ ------------------------------------------------
  9      ADR nΒ°1: A Symfony controller should be final.
 ------ ------------------------------------------------


                                                                                                                        
 [ERROR] Found 1 error                                                                                                  
                                                                                                                        

The new rule works correctly. And if we add the final keyword, the errors should disappear.

Checking bad practice

We saw how to enforce "good behaviours" in the two previous examples, but what about checking bad practices? When adding controllers, one important rule is that it they should stay thin and not contain business logic. Here is a new rule that checks that we don't instantiate objects inside them. This time we analyse the PhpParser\Node\Expr\New_ node type:

<?php

declare(strict_types=1);

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name\FullyQualified;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use Symfony\Component\HttpFoundation\Response;
use function Symfony\Component\String\u;

/**
 * @implements Rule<New_>
 */
final class NoNewInControllerRule implements Rule
{
    public function getNodeType(): string
    {
        return New_::class;
    }

    /**
     * @param New_ $node
     */
    public function processNode(Node $node, Scope $scope): array
    {
        // Only in controllers
        if (!$this->isInControllerNamespace($scope)) {
            return [];
        }

        // Trait are allowed
        if ($scope->isInTrait()) {
            return [];
        }

        if (!$node->class instanceof FullyQualified) {
            return [];
        }

        $classString = $node->class->toCodeString();

        // Exceptions are allowed
        if (is_a($classString, \Throwable::class, true)) {
            return [];
        }

        // Responses are allowed
        if (is_a($classString, Response::class, true)) {
            return [];
        }

        return [sprintf("You can't instanciate a %s object manually in controllers, create a service please.", $classString)];
    }

    /**
     * Check that the class belongs to the controller namespace.
     */
    private function isInControllerNamespace(Scope $scope): bool
    {
        return u($scope->getNamespace())->startsWith('App\Controller');
    }
}

Some explanations:

  • We check the controller namespace like we did before
  • We don't check traits because all my snippets are stored in traits so they can run in a real "controller" context
  • We check that we have a fully qualified class
  • Exceptions are allowed: e.g. throw new \InvalidArgumentException('Invalid date object.');
  • Responses are allowed: e.g. return New Response('This is a basic response');

Let's rerun PHPStan:

[08:48:01] coil@Mac-mini.local:/Users/coil/Sites/strangebuzz.com$ make stan
 249/249 [β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“] 100%

 ------ --------------------------------------------------------------------------------------------
  LineLine   src/Controller/AppController.php
 ------ --------------------------------------------------------------------------------------------
  37     You can't instanciate a \DateTime object manually in controllers, create a service please.
 ------ --------------------------------------------------------------------------------------------


                                                                                                                        
 [ERROR] Found 1 error                                                                                                  
                                                                                                                        

It works, and an error is detected in one of my controllers. It's the one that is responsible for displaying the homepage of this website (code below).

Note that this test is just an example because I knew I would have one error on this project. It is OK to pass the current date-time (infrastructure layer) to a service (application layer) in a controller. To check the instantiated class you can use $node->class->toCodeString(). In our case, it returns the \DateTime string.

Click here to see the content of my AppController.
    /**
     * @param array<string,int> $goals
     */
    #[Route(path: ['en' => '/en', 'fr' => '/fr'], name: 'homepage')]
    public function homepage(string $_locale, array $goals, ArticleRepository $articleRepository): Response
    {
        $data = [];
        $date = new \DateTime();
        $data['goals'] = $goals;
        $data['article'] = $articleRepository->findLastArticleForLang($_locale);
        $data['snippet'] = $articleRepository->findLastSnippetForLang($_locale);

Conclusion

We saw how to create custom PHPStan rules to check Symfony best practices and ADR. Of course, these are simple examples. The PHPStan analyser is very powerful, and you can check everything. If you use and like PHPStan, you should consider supporting it as I do with a PRO subscription πŸ˜‰.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  ADR


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil