Jibé Barth

🇫🇷 Web developer

[PHP-CS-Fixer] Configure any rule with except paths

Published Feb 12, 2023

Based on a true story.

So you are hyped by new possibility of PHP8, and you start a new Symfony Project! Thanks to maker-bundle, you create your first entities, and you get a beautiful class using PHP Attributes for doctrine.

Later, you want to add PHP-CS-Fixer, to keep your code consistency.

Finally, you decide to add a rule that add final to almost every class, as you read this article.

And 💥! All your tests are now failing, the new shiny project is unusable.

After inspect changes, all your doctrine entities are now final, and it breaks doctrine internals.


The documentation of that rule explain:

No exception and no configuration are intentional. Beside Doctrine entities and of course abstract classes, there is no single reason not to declare all classes final

So what the heck my doctrine entities are changed ?

Well, unfortunately, for now, the tool only support Doctrine annotations, not Doctrine attributes.

Does it mean we have to migrate all our entities to @ORM\Entity instead of #[ORM\Entity] ?

Deeping into source code, I found that we can avoid the change if we mark entity as final with the @final annotation.

<?php

namespace App\Entity;

use App\Repository\AwesomeEntityRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AwesomeEntityRepository::class)]
/** @final */
class AwesomeEntity
{
   // ...

For a quick workaround, it does the job. But it would be a lie. Do we want add lies in our codebase?

So I decided to write a custom PhpCsFixer rule that could do the job of FinalClassFixer, but configurable with a list of path where the rule should not apply.

The documentation has a part about that: https://cs.symfony.com/doc/custom_rules.html

<?php
// ...
return (new PhpCsFixer\Config())
    // ...
    ->registerCustomFixers([
        new CustomerFixer1(),
        new CustomerFixer2(),
    ])
    ->setRules([
        // ...
        'YourVendorName/custome_rule' => true,
        'YourVendorName/custome_rule_2' => true,
    ])
;

TLDR (give me the tip)

Create the two following files:

Then, alter your .php-cs-fixer.php configuration to register that custom rule:

<?php

$finder = (new PhpCsFixer\Finder())
    ->in(__DIR__)
    ->exclude('var')
;

return (new PhpCsFixer\Config())
    ->registerCustomFixers([
        new \App\Fixer\NotInProxyFixer()
    ])
    ->setRules([
        '@Symfony' => true,
        '@PSR12' => true,
        'Barth/not_in' => [
            'final_class' => ['except' => [
                'src/Entity',
            ]],
        ],
    ])
    ->setRiskyAllowed(true)
    ->setFinder($finder)
;

Additional notes

This custom rule should be able to handle any rule from PHP-CS-Fixer. You just have to move it into Barth/not_in rule, and add the except array where you don't want the rule to be applied.

    ->setRules([
-        'final_class' => true,
+        'Barth/not_in' => [
+            'final_class' => ['except' => [
+                'src/Entity',
+            ]],
        ],
    ])

final_class was my main issue, but it could be also used with the method_chaining_indentation when we create a bundle with a configuration and keep the custom indentation:

    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder('awesome_extension');
        $rootNode = $treeBuilder->getRootNode();

        $rootNode
            ->children()
                ->arrayNode('test')
                    ->canBeDisabled()
                ->end()
            ->end()
        ;

        return $treeBuilder;
    }
    ->setRules([
-        'method_chaining_indentation' => true,
+        'Barth/not_in' => [
+             'method_chaining_indentation' => ['except' => ['src/DependencyInjection']]
        ],
    ])

If your rule is configurable, you can also configure it beside the except key:

// ...
    ->setRules([
        'Barth/not_in' => [
            'header_comment' => [
                'header' => 'This file belong to AwesomePackage',
                'except' => ['src/Entity']
            ],
        ],
    ])

It could also be boring to copy paste these file on multiple project. I'll may create a composer package to distribute it easily.