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 ! 😎
» 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.4
- Symfony 6.4.15
- 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) À bientôt ! COil. 😊
Lire la doc Plus sur le web ADR
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 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 ! 😉
[🇫🇷] Quatrième article de l'année : "Création de règles PHPStan personnalisées pour votre projet Symfony" https://t.co/rjw9T3zOYB Relectures, retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 4/10 #phpstan #php #symfony #cs #adr /cc @FredBouchery
— COil #OnEstLaTech ✊ (@C0il) October 29, 2021