Aller au contenu principal
Retour au blog

PER-CS 3, PHP-CS-Fixer, Pint, Mago : quel formateur pour Symfony

PER-CS 3.0 et 3.1, PHP-CS-Fixer 3.95, Pint et mago format testés sur du PHP 8.5 : divergences, pièges de CI et formateur à retenir pour un projet Symfony.

Flavien Métivier · 9 min de lecture
Sommaire · 8 parties

Un formateur de code n’a rien de glamour, et pourtant c’est lui qui décide si vos revues de PR portent sur l’architecture ou sur les espaces. En 2026, le choix s’est compliqué : la norme PER-CS a absorbé les property hooks, la visibilité asymétrique et l’opérateur pipe, PHP-CS-Fixer tourne en parallèle par défaut, et Mago propose un formateur écrit en Rust. Nous avons passé un même fichier PHP 8.5 « piégeux » dans les trois outils, puis mesuré leurs écarts sur 2 245 fichiers réels.

PER-CS : ce que dit la norme en 2026

PER Coding Style est la norme de style de code PHP de PHP-FIG, qui succède à PSR-12. Sa version 3.0.0 a été publiée le 14 juillet 2025. Elle formalise la syntaxe moderne : types composés sans espaces autour de | et &, visibilité asymétrique (public private(set)), property hooks.

La page php-fig.org affiche désormais « 3.1 ». Ce qui est établi : un tag 3.1.0 existe dans le dépôt de la norme, sur un commit du 13 août 2026, sans release GitHub ni statut de vote affiché. La 3.1 impose notamment clone() toujours avec parenthèses, des case sans accolades, l’opérateur |> entouré d’espaces et placé en début de ligne dans une chaîne multiligne, et les closures vides sur une seule ligne. Aucun outil ne l’applique encore : PHP-CS-Fixer n’a pas de jeu de règles 3.1, et l’issue de support reste ouverte. Ne promettez donc pas la « conformité PER 3.1 » dans votre documentation d’équipe.

Les trois outils, et ce qu’ils sont vraiment

  • PHP-CS-Fixer 3.95.27 (22 septembre 2026) : la référence. Il fournit les jeux @PER-CS3x0 et @PER-CS3.0, à sortie identique — mais la notation avec point est signalée dépréciée en mode verbeux : « Use "@PER-CS3x0" instead ». @PER-CS est un alias de la dernière version, aujourd’hui la 3.0. Le runner parallèle est actif par défaut.
  • Laravel Pint 1.32.1 (10 septembre 2026) : un wrapper de PHP-CS-Fixer. Son preset per vaut @PER-CS plus no_unused_imports, et sa sortie a été strictement identique à celle de PHP-CS-Fixer sur notre fichier. Attention, son défaut est le preset laravel, qui n’est pas PER.
  • mago format (Mago 1.57.0) : un binaire Rust, sans PHP, au style proche de PER par défaut, avec des presets psr-12, laravel et drupal. Il ne fait que de la mise en forme. Le reste de la toolchain Mago est détaillé dans notre article sur Mago en complément de PHPStan.

Le test : un fichier PHP 8.5 piégeux, trois outils

Notre fichier contient un enum avec constante privée, un match sur une ligne, des property hooks, public private(set), des types intersection, clone($this, [...]), une chaîne de pipe, des closures vides, un switch avec un bloc entre accolades, un array() et une classe anonyme avec attribut. Premier enseignement avant même de formater : en PHP 8.5, une arrow function à droite de |> doit être entre parenthèses, sinon c’est une erreur fatale (« Arrow functions on the right hand side of |> must be parenthesized »). Les trois outils préservent ces parenthèses.

php
<?php
// Extrait du fichier de test : syntaxe PHP 8.5 que les outils doivent préserver
final class Slugger
{
    public private(set) int $count = 0;

    public string $label {
        get => strtoupper($this->label);
    }

    public function slug(string $input): string
    {
        return $input
            |> trim(...)
            |> (fn(string $s): string => strtolower($s))
            |> (fn(string $s): string => str_replace(' ', '-', $s));
    }

    public function withLabel(string $label): static
    {
        return clone($this, ['label' => $label]);
    }
}
  • @PER-CS3x0 et Pint per : séparent les imports groupés, passent array() en [], mettent des espaces autour du point de concaténation. Mais ils laissent passer un match compacté sans espace après la virgule, un attribut sur la même ligne que la classe ou un case 1: { ... }. Le jeu PER est volontairement minimal : « conforme PER » ne veut pas dire « code uniforme ».
  • @Symfony : réécrit en noms complets (\Countable), met une espace dans fn (, supprime les espaces autour du point, passe les corps vides sur deux lignes, impose le style Yoda — et écrit clone ($this, ...) avec une espace, contraire à la 3.1.
  • Pint par défaut (laravel) : mêmes écarts que @Symfony sur fn ( et la concaténation, plus ! empty(). Lancer pint sans pint.json ne produit pas du PER.
  • mago format : le seul qui réécrit vraiment la mise en page — un paramètre de constructeur promu par ligne, un bras de match par ligne, imports triés, appels longs coupés à 120 caractères. Il préserve hooks, visibilité asymétrique et pipe, mais ne touche ni à array(), ni à and, ni aux case entre accolades. Parmi ses presets, seul default reste cohérent avec PER.

@Symfony ou @PER-CS pour un projet Symfony ?

@Symfony étend @PER-CS — c’est la description officielle du jeu — mais il le surcharge sur plusieurs points : fn (, concaténation, accolades vides, noms complets. Un projet en @Symfony n’est donc pas strictement PER. Mesuré sur 2 245 fichiers des composants Symfony et Doctrine : @PER-CS3.0 en signale 1 188 à corriger, @Symfony 833 — logique, ce code suit le style Symfony. C’est un bon indicateur du volume de diff qui vous attend en changeant de jeu.

  • Base existante en @Symfony : gardez-le. Un reformatage massif sans raison ne rapporte rien.
  • Nouveau projet, ou exigence d’interopérabilité : @PER-CS3x0, complété des règles que PER ne couvre pas — tri des imports, virgule finale, declare(strict_types=1).
  • Hybride : @Symfony puis @PER-CS3x0, le dernier l’emportant en cas de conflit. Testé : PER gagne sur fn(, la concaténation et les accolades, Symfony apporte l’ordre des imports et les closures static.
php
<?php
// .php-cs-fixer.dist.php : hybride @Symfony + PER-CS 3.0, testé sur PHP-CS-Fixer 3.95.27
declare(strict_types=1);

use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;

$finder = (new Finder())
    ->in([__DIR__ . '/src', __DIR__ . '/tests'])
    ->exclude('var');

return (new Config())
    ->setFinder($finder)
    ->setRiskyAllowed(true) // requis par declare_strict_types
    ->setParallelConfig(ParallelConfigFactory::detect())
    ->setRules([
        '@Symfony' => true,
        // PER passe après @Symfony : il gagne en cas de conflit
        '@PER-CS3x0' => true,
        'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'],
        'global_namespace_import' => ['import_classes' => true, 'import_functions' => false, 'import_constants' => false],
        'declare_strict_types' => true,
        'trailing_comma_in_multiline' => ['elements' => ['arguments', 'arrays', 'match', 'parameters']],
    ]);

Cette configuration a été exécutée telle quelle sur PHP-CS-Fixer 3.95.27 : sur notre fichier PHP 8.5, elle n’ajoute que declare(strict_types=1) et préserve hooks, pipe et clone(). declare_strict_types est une règle « risky », d’où setRiskyAllowed(true). Nous avons en revanche laissé de côté les jeux complets @Symfony:risky et @PER-CS3x0:risky : ils méritent une section à part.

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.

Calculer mon audit →

Les pièges qui font passer une CI au vert sans rien vérifier

  • Le PHP qui exécute l’outil doit être au moins aussi récent que la syntaxe du code. PHP-CS-Fixer analyse avec le PHP qui le lance. Sur php:8.4-cli, notre fichier 8.5 n’a pas été corrigé (« Files that were not fixed due to errors reported during linting ») — et fix est sorti en code 0. Seul --dry-run sort en erreur. Un job qui lance fix « pour vérifier » est vert sans rien contrôler : en CI, toujours --dry-run --diff.
  • Mago sans chemin. mago format --check lancé sans argument et sans mago.toml a répondu « All files are already formatted »… sur un dossier où 1 634 fichiers sur 2 245 devaient changer. Passez toujours les chemins, ou déclarez [source] paths.
  • Les règles risky changent le comportement. Avec @Symfony:risky, $a === '' || $b > 3 and !empty($c) est devenu '' === $a || $b > 3 && !empty($c). Or and a une précédence plus faible que || : les deux expressions ne sont pas équivalentes. Relisez chaque diff risky avant d’imposer ces règles.
  • Les annotations orphelines. Une annotation // @php-cs-fixer-ignore qui vise une règle absente du jeu actif fait lever une exception et arrête tout le run. Pensez-y en changeant de jeu.
  • Pas d’idempotence en une passe avec le jeu hybride : un second fix a encore modifié un fichier. Lancez deux passes en local avant de commiter la configuration.
  • Le premier passage de Mago reformate massivement : un commit dédié, référencé dans .git-blame-ignore-revs, pour ne pas polluer git blame.

Performance : ce que coûte chaque outil

Mesures indicatives sur 2 245 fichiers (environ 280 000 lignes), machine à 24 cœurs, en Docker, en mode vérification, sans cache :

  • PHP-CS-Fixer @PER-CS3.0 : 5,2 à 5,5 secondes en parallèle, 26 secondes en séquentiel.
  • PHP-CS-Fixer @Symfony : 8,7 à 9,1 secondes en parallèle.
  • Pint per : 28 secondes par défaut, car séquentiel ; 6,5 secondes avec --parallel, encore marqué expérimental.
  • mago format --check : 0,41 à 0,44 seconde.

Mago est donc une douzaine de fois plus rapide que PHP-CS-Fixer en parallèle — mais il ne fait pas le même travail : pas de transformation sémantique, pas d’imports nettoyés, pas de declare(strict_types=1). Le parallélisme de PHP-CS-Fixer divise le temps par cinq, au prix d’une mémoire plus élevée. Ces chiffres dépendent du nombre de cœurs ; ils donnent un ordre de grandeur, pas un benchmark.

Quel formateur pour un projet Symfony ?

Pour un projet Symfony, PHP-CS-Fixer reste le choix par défaut : ses règles sémantiques — imports, typage strict, ordre des éléments — sont son véritable apport, et il s’intègre à une chaîne où Rector refactore avant le formatage. Mago s’envisage en pre-commit sur un gros monorepo pour la mise en page, mais les deux outils se disputent la même mise en forme et peuvent se contredire : désactivez les règles d’espacement côté PHP-CS-Fixer, ou lancez Mago en dernier, et validez la cohabitation sur votre base. Hors Laravel, Pint n’apporte rien que PHP-CS-Fixer n’offre déjà.

yaml
# .github/workflows/code-style.yml
name: Code style
on: [pull_request]
permissions:
  contents: read
jobs:
  php-cs-fixer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: shivammathur/setup-php@34a5396826718e0013f08e3e639d1c315d5f6b23 # 2.35.0
        with:
          php-version: '8.5'   # au moins la version de la syntaxe du code
      - run: composer install --no-interaction --prefer-dist
      # --dry-run : seul mode qui échoue si un fichier n'a pas pu être analysé
      - run: vendor/bin/php-cs-fixer fix --dry-run --diff --show-progress=none

Un formateur règle la forme, pas le fond : il ne dit rien de la correction des types, que l’analyse statique vérifie — notre comparatif PHPStan, Psalm et Mago traite ce volet.

Ce qu’il faut retenir

  • Passez à @PER-CS3x0 : la notation @PER-CS3.0 fonctionne mais est dépréciée.
  • Épinglez PHP-CS-Fixer et Mago : Mago sort plusieurs versions par semaine.
  • Exécutez PHP-CS-Fixer sur un PHP au moins aussi récent que votre code, en --dry-run en CI.
  • Choisissez explicitement entre @Symfony et PER ; l’hybride demande deux passes de rodage.
  • Relisez les règles risky avant de les activer.
  • Ne revendiquez pas la conformité PER 3.1 : aucun outil ne l’applique à ce jour.

Ce dernier article referme une série dont le fil conducteur s’est imposé de lui-même : Composer bloque les malwares sans configuration, PHPUnit 13 pousse à sceller les mocks, PHPStan scellera les array shapes par défaut. Les garde-fous passent de l’option au défaut, et le formateur n’y échappe pas : le style n’est plus un débat, c’est une porte de CI. Action pour demain matin : ouvrez une branche, lancez votre formateur en --dry-run sur le dépôt, et comptez les fichiers touchés.

Un formateur uniformise la forme du code, mais il ne dit rien de sa solidité : dette accumulée, failles, zones sans tests. Si vous gérez plusieurs projets Symfony et voulez savoir où ils en sont vraiment avant de fixer vos règles, c’est ce que couvre l’audit Bear Scan, avec des recommandations priorisées et actionnables.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.