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-CS3x0et@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-CSest 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
pervaut@PER-CSplusno_unused_imports, et sa sortie a été strictement identique à celle de PHP-CS-Fixer sur notre fichier. Attention, son défaut est le presetlaravel, 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 presetspsr-12,laraveletdrupal. 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
// 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-CS3x0et Pintper: séparent les imports groupés, passentarray()en[], mettent des espaces autour du point de concaténation. Mais ils laissent passer unmatchcompacté sans espace après la virgule, un attribut sur la même ligne que la classe ou uncase 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 dansfn (, supprime les espaces autour du point, passe les corps vides sur deux lignes, impose le style Yoda — et écritclone ($this, ...)avec une espace, contraire à la 3.1.- Pint par défaut (
laravel) : mêmes écarts que@Symfonysurfn (et la concaténation, plus! empty(). Lancerpintsanspint.jsonne 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 dematchpar 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 auxcaseentre accolades. Parmi ses presets, seuldefaultreste 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 :
@Symfonypuis@PER-CS3x0, le dernier l’emportant en cas de conflit. Testé : PER gagne surfn(, la concaténation et les accolades, Symfony apporte l’ordre des imports et les closuresstatic.
<?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.
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 ») — etfixest sorti en code 0. Seul--dry-runsort en erreur. Un job qui lancefix« pour vérifier » est vert sans rien contrôler : en CI, toujours--dry-run --diff. - Mago sans chemin.
mago format --checklancé sans argument et sansmago.tomla 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). Oranda 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-ignorequi 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
fixa 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 polluergit 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à.
# .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=noneUn 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.0fonctionne 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-runen CI. - Choisissez explicitement entre
@Symfonyet 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.

É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