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.3
- Symfony 6.4.12
- PHPStan 1.9.2
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 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');
}
}
Some explanations: the getNodeType()
function allows us to filter the nodes we want to analyse; we want the classes which are represented by the PHPStan\Node\InClassNode
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, EntityManagerInterface $entityManager): Response
{
$data = [];
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. π
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 )
- Report any error/typo.
- Report something that could be improved.
- Like and retweet!
- Follow me on Twitter Follow me on Twitter
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges π .
Thank you for reading! And see you soon on Strangebuzz! π
[π¬π§] Fourth blog post of the year: "Creating custom PHPStan rules for your Symfony project" https://t.co/XGbyXMrfJS Proofreading, comments, likes and retweets are welcome! π Annual goal: 4/10 #phpstan #php #symfony #cs #adr /cc @OndrejMirtes @phpstan
— COil #OnEstLaTech β (@C0il) October 29, 2021