Création de règles PHPStan personnalisées pour votre projet Symfony

Publié le 28/10/2021 • Actualisé le 28/10/2021

Dans cet article nous voyons comment créer des règles PHPStan personnalisées pour un projet Symfony. Nous allons contrôler des bonnes pratiques Symfony mais aussi d'autres règles plus spécifiques. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 774" (du 25 au 31 ocotbre 2021).

Prérequis

Je présumerai que vous avez au moins les connaissances fondamentales de Symfony et que vous savez comment utiliser un outil d'analyse statique comme PHPStan.

Configuration

  • PHP 8.3
  • Symfony 6.4.6
  • PHPStan 1.9.2

Introduction

J'ai toujours une note sur ce blog avec quelques idées d'articles ; une d'entre elles était la création de règles personnalisées PHPStan, mais je manquais d'idées précises de ce que je voulais faire. Le 21 octobre 2021, j'ai assisté au forum PHP à Paris. La présentation de Frédéric Bouchery était liée à ce sujet (vous pouvez trouver les diapositives ici). Il nous a montré plusieurs exemples sur la manière de procéder. Ça m'a motivé pour enfin écrire cet article. Alors, merci Fréd. 😊

But

Nous voyons comment écrire les règles PHPStan personnalisées pour un projet Symfony et vérifier des ADR : Architectural Decision Records.

Analyse d'un contrôleur Symfony

En premier lieu, avant de créer une règle personnalisée, nous devons avoir quelque chose à analyser qui lève des erreurs. Nous allons inspecter un contrôleur Symfony, utilisons le bundle maker pour en générer un ; appelons-le StanController, exécutez bin/console make:controller et entrez Stan. Voici ce qui est généré (ne pas oublier de supprimer le template généré) :

<?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',
        ]);
    }
}

Comme vous pouvez le voir, ce petit contrôleur tout neuf étend le contrôleur abstrait de Symfony AbstractController. Mais pourquoi ? Si une classe étend AbstractController, alors elle est automatiquement enregistrée comme service. De plus, quand on utilise la configuration par défaut (avec l'auto-configuration activée), elle reçoit le tag controller.argument_value_resolver grâce à cette configuration :

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

Comme nous avons utilisé le bundle maker, il suit les bonnes pratiques Symfony. Voyons voir ça. Effectivement, on peut trouver cette règle dans la référence des bonnes pratiques :

“Faites étendre votre contrôleur du contrôleur de base AbstractController”


C'est la première règle que nous allons implémenter. Retirons l'instruction extends AbstractController et retournons new Response(); au lieu d'utiliser la fonction render(). Le contrôleur est toujours valide, mais il ne satisfait plus les bonnes pratiques Symfony.

Avant de créer la règle, vérifions que tout est correct :

[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                                                                                                         
                                                                                                                        

Vérifier une bonne pratique Symfony

Maintenant, écrivons une règle qui lève une erreur si un contrôleur n'étend pas le contrôleur abstrait Symfony de base. On doit créer une classe qui étend la classe PHPStan\Rules\Rule. On a deux fonctions à implémenter getNodeType() et processNode(). Mettons cette classe dans src/PHPStan. Notez que comme ce fichier est dans le répertoire src/, PHPStan va aussi l'analyser.

<?php

declare(strict_types=1);

// src/PHPStan/ControllerExtendsSymfonyRule.php

namespace App\PHPStan;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

use function Symfony\Component\String\u;

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

    /**
     * @param InClassNode $node
     *
     * @see https://github.com/phpstan/phpstan/issues/7099
     */
    public function processNode(Node $node, Scope $scope): array
    {
        /** @var ClassReflection $classReflection */
        $classReflection = $scope->getClassReflection();
        if (!$this->isInControllerNamespace($classReflection)) {
            return [];
        }

        if (!$classReflection->isSubclassOf(AbstractController::class)) {
            return [sprintf('Controllers should extend %s.', AbstractController::class)];
        }

        return [];
    }

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

Quelques explications : la fonction getNodeType() nous permet de filtrer les nœuds que nous voulons traiter. Ici, nous voulons vérifier les classes, celles-ci sont représentées par la classe de nœud PHPStan\Node\InClassNode. On regarde l'espace de nom dans la fonction processNode() car nous ne voulons appliquer ce contrôle qu'aux classes dans le répertoire src/Controller. Maintenant, nous pouvons faire le test principal, nous vérifions que la classe est un enfant de Symfony\Bundle\FrameworkBundle\Controller\AbstractController.

Nous avons ajouté une nouvelle règle. Nous devons indiquer à PHPstan de l'utiliser. On la déclare dans le fichier de configuration phpstan.neon. Vous pouvez trouver ma configuration complète dans ce snippet.

rules:
    - App\PHPStan\ControllerExtendsSymfonyRule

Maintenant, lançons PHPStan pour vérifier si la règle fonctionne :

 249/249 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

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


                                                                                                                        
 [ERROR] Found 1 error                                                                                                  
                                                                                                                        

Oui, ça fonctionne en effet. Remettez l'instruction extends et vérifiez que l'erreur disparait. Voyons un autre exemple.

Vérification d'un ADR

Qu'est-ce qu'un ADR ? Cet acronyme signifie Architectural Decision Records ou enregistrement de décision d'architecture en français. C'est une règle qu'on décide d'appliquer à un projet. Ces règles peuvent être documentées, bien sûr. Comment être sûr qu'elles sont bien appliquées ? On peut créer une autre règle personnalisée. Finalement, une bonne pratique Symfony est juste un ADR partagé par une flopée de projets et de gens 🙂.

Imaginons que nous voulions instaurer plusieurs vérifications sur nos contrôleurs. Nous avons déjà vu comment nous pouvions filtrer les nœuds et les espaces de noms. Refactorisons ce que nous avons fait (N'ayez pas peur de refactoriser ! ). Nous créons une règle abstraite qui sera dédiée aux contrôleurs ; la voici :

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

Maintenant, nous pouvons créer une règle qui l'étend. Ici, nous faisons un simple test qui vérifie si la classe est finale. En effet, les contrôleurs ne sont pas supposés être étendus. D'ailleurs PHPStorm avec l'excellent plugin Php Inspections (EA Extended) nous met en garde :

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

Nous n'avons qu'à implémenter la fonction processNode() cette fois :

<?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 [];
    }
}

Le test principal est explicite, on doit apeller la fonction isFinal() sur l'objet $node. Nous devons déclarer cette nouvelle règle comme nous l'avons fait précédemment. Relançons PHPStan :

 249/249 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

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


                                                                                                                        
 [ERROR] Found 1 error                                                                                                  
                                                                                                                        

La nouvelle règle fonctionne correctement. Si on ajoute le mot-clé final, l'erreur devrait disparaitre.

Vérification d'une mauvaise pratique

Jusqu'ici on a contrôlé l'application de "bons comportements", mais on peut aussi détecter de mauvaises pratiques. Quand on utilise des contrôleurs Symfony, une des règles les plus importantes est qu'ils devraient rester très légers et ne comporter aucune logique métier. Voici une règle qui va vérifier qu'on n'instancie aucun objet en leur sein. Cette fois nous analysons le type de nœud PhpParser\Node\Expr\New_ :

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

Quelques explications :

  • On vérifie l'espace de noms du contrôleur comme nous l'avons fait précédemment
  • On ne vérifie pas les traits, car sur ce projet, tous les snippets y sont stockés pour être exécutés dans un réel contexte "contrôleur"
  • On vérifie qu'on a une classe pleinement qualifiée
  • Les exceptions sont autorisées : ex : throw new \InvalidArgumentException('Invalid date object.');
  • Les réponses sont autorisées : ex : return New Response('This is a basic response');

Relançons 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                                                                                                  
                                                                                                                        

Ça marche et une erreur est détectée dans l'un de mes contrôleurs. C'est celui qui gère l'affichage de la page d'accueil de ce site (code ci-dessous).

Veuillez noter que ce test n'est qu'un exemple, car je savais que j'aurais une erreur de ce type sur ce projet. Il est correct de passer la date courante (couche infrastructure) pour la passer à un service (couche application) dans un contrôleur. Pour vérifier la classe qui est instanciée, vous pouvez utiliser $node->class->toCodeString(). Dans notre cas, cela retourne la chaîne \DateTime.

Cliquez ici pour voir le contenu de AppController.

    /**
     * @param array<string,int> $goals
     */
    #[Route(path: ['en' => '/en', 'fr' => '/fr'], name: 'homepage')]
    public function homepage(string $_locale, array $goals, EntityManagerInterface $entityManager): Response
    {
        $data = [];

Conclusion

Nous avons vu comment créer des règles PHPStan personnalisées pour vérifier des bonnes pratiques et des ADR. Bien sûr, ce sont des exemples simples. L'analyseur de PHPStan est très puissant et on peut réellement tout tester. Si vous utilisez et aimez PHPStan, vous pouvez considérer le supporter en souscrivant à une offre PRO comme je le fais 😉.

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc  Plus sur le web  ADR

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil