Le problème

Prenons une boutique en ligne organisée en monorepo : les services Symfony order, stock, shipping, billing et le package partagé app-contracts. L'ensemble est orchestré par moon. Sans outillage de qualité commun, les mêmes défauts reviennent d'une pull request à l'autre.

  • Les revues de code discutent d'indentation et d'ordre des imports, au lieu du comportement.
  • Un processor du service order appelle $order->getCustomer()->getEmail(), alors que getCustomer() peut renvoyer null. L'erreur apparaît en production, sur une commande sans client rattaché.
  • Une entité Reservation du service stock importe une classe de ressource API. Le schéma de base et le contrat d'API se retrouvent liés.
  • Un argument de service mal typé ne casse qu'à l'exécution, quand le service est instancié.
  • Une faille publiée sur une dépendance n'est découverte qu'au hasard d'une mise à jour.

La réponse habituelle s'appelle SonarQube. Son édition gratuite auto-hébergée, Community Build, n'analyse que la branche principale : le constat arrive après la fusion. Les éditions auto-hébergées qui analysent les pull requests sont payantes.

Sans ce budget, la question devient : quels outils gratuits couvrent ces défauts, et à quel moment les lancer ?

Les options

SonarQube Community Build seul

Community Build est gratuit, auto-hébergé et publié chaque mois. Il analyse le PHP : sa documentation annonce une prise en charge complète de PHP 5.0 à 8.4.

  • Ce qu'il règle : un tableau de bord unique, un historique de la dette, des règles maintenues par l'éditeur.
  • Ce qu'il coûte : un serveur à exploiter. Surtout, il n'analyse que la branche principale : ni les autres branches, ni les pull requests. La détection des vulnérabilités d'injection (l'analyse de flux, ou taint analysis) n'y figure pas.

Un plugin communautaire, non supporté par SonarSource, ajoute les branches et les pull requests. Sa version suit celle du serveur : au 1er octobre 2026, il vise la 26.5, quand Community Build en est à 26.9.

Une édition payante de SonarQube

  • Ce qu'elle règle : l'analyse des branches et des pull requests, et le statut de la quality gate sur chaque pull request.
  • Ce qu'elle coûte : pour SonarQube Server, une licence par instance et par an, calculée sur le nombre de lignes de code.

L'offre gratuite de SonarQube Cloud analyse aussi les pull requests qui visent la branche principale, jusqu'à 50 000 lignes de code privé. Le code est alors analysé hors de vos murs.

Une pile d'outils open source, lancée par le monorepo

  • Ce qu'elle règle : chaque outil cible une famille de défauts. Il tourne en local comme en CI, et sa configuration vit dans le dépôt. Son code de sortie suffit à bloquer une pull request.
  • Ce qu'elle coûte : plusieurs outils à configurer et à mettre à jour, et pas de tableau de bord consolidé.

Un outil tout-en-un

Deux outils visent ce créneau. PHP Insights agrège PHP-CS-Fixer, PHP_CodeSniffer et Slevomat Coding Standard dans un rapport noté. Mago, écrit en Rust, réunit formateur, linter, analyseur statique et règles d'architecture dans un seul binaire.

  • Ce qu'il règle : une installation, une configuration, une seule sortie.
  • Ce qu'il coûte : PHP Insights recouvre les outils qu'il agrège, et sa version 2.15 demande PHP 8.4. Mago est jeune : version 1.0 en décembre 2025, version 1.50 le 21 septembre 2026.

Notre recommandation

PHPStan au niveau 10, PHP-CS-Fixer, Rector, Deptrac, composer audit et les linters Symfony, lancés par moon en local comme en CI.

Chaque outil attrape une famille de défauts que les autres ne voient pas. Une baseline PHPStan absorbe la dette existante, pour viser le niveau maximal dès le premier jour. La configuration est versionnée avec le code, et un développeur obtient en local le verdict de la CI.

Outil Ce qu'il attrape
PHP-CS-Fixer style, imports, syntaxe à moderniser
Linters Symfony et Doctrine YAML invalide, service mal câblé, mapping incohérent
PHPStan et ses extensions types, appels sur null, DQL invalide
Deptrac dépendance interdite entre couches
Rector en --dry-run code à migrer vers la version cible de PHP ou Symfony
composer audit dépendance vulnérable ou abandonnée
PhpMetrics complexité, couplage, maintenabilité par classe

Rapide avant le commit, complet sur la pull request, global sur main 1. Avant le commit sur le poste, projets touchés moon run :lint --affected --status=staged Un échec bloque le commit. PHP-CS-Fixer en vérification lint:yaml, lint:container doctrine:schema:validate --skip-sync push 2. Pull request en CI, projets affectés moon ci :lint :analyse :audit Un échec bloque la fusion. Tout le lint, puis : PHPStan au niveau 10, avec baseline Deptrac, Rector en --dry-run composer audit --locked fusion sur main 3. Périodique job planifié sur main moon run :audit :metrics Informe, ne bloque pas. composer audit de tous les projets PhpMetrics : rapport HTML SonarQube Community Build, s'il est installé

Selon le contexte, nous ajoutons :

  • Psalm, pour sa seule analyse de flux (--taint-analysis), avec psalm/plugin-symfony qui l'étend à Symfony. Elle suit une entrée utilisateur jusqu'à une requête SQL ou une sortie HTML. C'est ce qui manque à Community Build.
  • Twig CS Fixer et lint:twig, pour un service qui rend des templates, comme les e-mails de shipping.
  • Infection, sur le code le plus sensible, comme le calcul des montants de billing. Les tests de mutation révèlent les tests qui passent sans rien vérifier. En pull request, --git-diff-lines limite les mutations aux lignes modifiées.
  • PHP_CodeSniffer, si une norme de codage dont vous avez besoin n'existe que pour lui. PHPCSStandards le maintient depuis l'abandon du dépôt d'origine, sous le même nom de paquet.

Nous laissons de côté :

  • PHPMD : sa dernière version stable, la 2.15.0, date du 11 décembre 2023. La branche 3.x est active, mais aucune version 3 n'est publiée au 1er octobre 2026.
  • GrumPHP : il installe ses propres hooks git depuis un projet Composer. Dans un monorepo, moon gère déjà les hooks, avec les mêmes tâches que la CI.
  • PHP Insights : il recoupe PHP-CS-Fixer, déjà dans la pile, et sa version 2.15 dépend encore de PHP_CodeSniffer 3.
  • Mago, pour l'instant : nous l'évaluons sur une branche, sans bloquer, avant de lui confier une pull request.

Les compromis assumés

Plusieurs outils au lieu d'un tableau de bord. Chaque outil a sa configuration, sa sortie et ses montées de version. Nous les traitons comme les autres dépendances de développement : épinglés dans composer.lock, mis à jour par pull request.

Pas de vue consolidée. Personne ne voit d'un coup d'œil la dette de tout le monorepo. PhpMetrics et, le cas échéant, Community Build sur main comblent une partie de ce manque.

Une baseline à faire décroître. Elle accepte la dette existante pour bloquer la nouvelle. Elle ne décroît que si quelqu'un s'en occupe, et une baseline qui grossit se refuse en revue.

Un niveau 10 exigeant. À ce niveau, PHPStan refuse toute opération sur une valeur mixed, même implicite. Le code qui lit des tableaux non typés (payloads, configuration) demande des annotations ou des DTO.

Le signal qui doit faire réexaminer ce choix : un audit qui exige des rapports de sécurité consolidés. Ou des rapports séparés que plus personne ne lit. Une édition commerciale de SonarQube redevient alors une option à chiffrer.

Mise en œuvre

Les exemples visent Symfony 7.4 et PHP 8.4 ; ils valent aussi pour Symfony 8. Chaque service installe ses outils en dépendances de développement.

composer require --dev phpstan/phpstan phpstan/extension-installer \
  phpstan/phpstan-symfony phpstan/phpstan-doctrine \
  friendsofphp/php-cs-fixer rector/rector deptrac/deptrac phpmetrics/phpmetrics

1. PHPStan au niveau 10, avec une baseline

# phpstan.neon (service order)
includes:
  - phpstan-baseline.neon

parameters:
  level: 10
  paths:
    - src/
    - tests/
  symfony:
    containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
  doctrine:
    objectManagerLoader: tests/object-manager.php

Avec phpstan/extension-installer, les extensions Symfony et Doctrine s'activent seules, règles comprises. La première lit le conteneur compilé et connaît le type de chaque service. La seconde charge l'entity manager (script tests/object-manager.php de sa documentation) pour vérifier le DQL et l'accord entre colonnes et propriétés.

Nous écrivons 10 plutôt que max. L'alias max suit le niveau le plus élevé, et PHPStan en a ajouté un avec sa version 2.0. Avec un chiffre, la montée de niveau reste une décision.

La baseline enregistre les erreurs existantes, pour ne bloquer que les nouvelles :

vendor/bin/phpstan analyse --generate-baseline

On lance cette commande avant d'ajouter phpstan-baseline.neon aux includes : PHPStan refuse d'inclure un fichier absent.

Le fichier phpstan-baseline.neon est commité. Quand une erreur est corrigée, PHPStan signale l'entrée devenue inutile, car reportUnmatchedIgnoredErrors est actif par défaut. On régénère alors la baseline : elle diminue, et une hausse se voit dans le diff.

2. Le style avec PHP-CS-Fixer

// .php-cs-fixer.dist.php (service order)
use PhpCsFixer\Config;
use PhpCsFixer\Finder;

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

return (new Config())
    ->setRiskyAllowed(true)
    ->setRules([
        '@Symfony' => true,
        '@Symfony:risky' => true,
    ])
    ->setFinder($finder);

En local, vendor/bin/php-cs-fixer fix corrige. En CI, vendor/bin/php-cs-fixer check --diff échoue et affiche la correction attendue. Les règles « risky » peuvent changer le comportement du code : nous les activons sur un service couvert par des tests.

3. Rector en mode vérification

// rector.php (service order)
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withPhpSets()
    ->withComposerBased(doctrine: true, phpunit: true, symfony: true)
    ->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml');

withPhpSets() sans argument lit la version de PHP dans composer.json. withComposerBased() charge les règles qui correspondent aux versions installées de Symfony, Doctrine et PHPUnit. En CI, vendor/bin/rector process --dry-run échoue s'il reste du code à migrer.

Pour une montée de version de Symfony, on lance vendor/bin/rector process sans --dry-run. Le diff se relit ensuite comme une pull request ordinaire.

4. Deptrac et les couches du service order

Dans le service order, les ressources API (src/ApiResource) sont des DTO. Les providers et processors (src/State) font le lien avec les entités Doctrine. Deptrac transforme cette règle en contrôle.

# deptrac.yaml (service order)
deptrac:
  paths:
    - src/
  layers:
    - name: Controller
      collectors:
        - type: directory
          value: src/Controller/.*
    - name: ApiResource
      collectors:
        - type: directory
          value: src/ApiResource/.*
    - name: State
      collectors:
        - type: directory
          value: src/State/.*
    - name: Messaging
      collectors:
        - type: directory
          value: src/Messaging/.*
        - type: directory
          value: src/MessageHandler/.*
    - name: Entity
      collectors:
        - type: directory
          value: src/Entity/.*
        - type: directory
          value: src/Repository/.*
  ruleset:
    Controller: [Entity, Messaging]
    # ApiResource -> Entity : stateOptions(entityClass: Order::class) d'API Platform
    ApiResource: [Entity]
    State: [ApiResource, Entity, Messaging]
    Messaging: [Entity]
    Entity: ~

Par défaut, Deptrac interdit toute dépendance entre couches : le ruleset liste les seules autorisées. Entités et repositories forment une seule couche, car #[ORM\Entity(repositoryClass: ...)] lie l'entité à son repository. La couche Entity ne dépend de rien : avec les mêmes couches dans stock, l'entité Reservation du problème fait échouer la pull request.

Les classes hors couches (Symfony, Doctrine, app-contracts) ne sont pas contrôlées ici. Pour une dette existante, --formatter=baseline écrit un fichier deptrac.baseline.yaml à importer. Le paquet s'appelle désormais deptrac/deptrac : qossmic/deptrac est marqué abandonné.

5. Les linters Symfony et Doctrine, et l'audit des dépendances

Symfony, Doctrine et Composer fournissent leurs propres contrôles, sans dépendance supplémentaire :

php bin/console lint:yaml config --parse-tags
php bin/console lint:container
php bin/console doctrine:schema:validate --skip-sync
composer audit --locked

lint:container vérifie que les arguments injectés correspondent aux types déclarés. doctrine:schema:validate --skip-sync valide le mapping sans le comparer à la base de données.

composer audit confronte le composer.lock aux avis de sécurité. Depuis Composer 2.7, il échoue aussi par défaut sur les paquets abandonnés.

6. Les tâches moon, en local et en CI

Chaque service déclare ses tâches dans son moon.yml :

# moon.yml (service order)
language: php
layer: application
dependsOn:
  - app-contracts

tasks:
  lint:
    script: >-
      vendor/bin/php-cs-fixer check --diff
      && php bin/console lint:yaml config --parse-tags
      && php bin/console lint:container
      && php bin/console doctrine:schema:validate --skip-sync
  analyse:
    script: >-
      php bin/console cache:warmup --env=dev
      && vendor/bin/phpstan analyse --no-progress
      && vendor/bin/deptrac analyse --no-progress
      && vendor/bin/rector process --dry-run --no-progress-bar
  audit:
    command: composer audit --locked
    options:
      cache: false
      runInCI: always
  metrics:
    command: vendor/bin/phpmetrics --report-html=var/phpmetrics src

La tâche analyse compile d'abord le conteneur de dev, que lisent PHPStan et Rector. Les avis de sécurité changent sans que le code change : audit ne passe donc pas par le cache et tourne à chaque CI.

Le hook de pre-commit ne lance que le lint des projets touchés par les fichiers indexés :

# .moon/workspace.yml
vcs:
  defaultBranch: 'main'
  hooks:
    pre-commit:
      - moon run :lint --affected --status=staged
  sync: true

Sans defaultBranch, moon prend master comme base de comparaison quand la CI ne lui en fournit pas.

En CI, une seule commande lance les tâches des projets affectés par la pull request :

moon ci :lint :analyse :audit

moon ci compare la branche à sa base et ne garde que les tâches concernées. Il lui faut l'historique complet du dépôt : un clone superficiel fausse la détection. Les règles de protection de branche rendent ce job obligatoire avant la fusion.

7. Le rapport périodique

Un job planifié, sur main, relance l'audit et le rapport PhpMetrics de tous les projets :

moon run :audit :metrics

Une faille publiée après la dernière pull request apparaît ainsi sans attendre la suivante. Si Community Build est installé, le même job lui envoie l'analyse de main.

PhpMetrics produit un rapport HTML : complexité, couplage et indice de maintenabilité par classe. Sa branche 2.x reste publiée (2.11.0 le 9 août 2026), la 3.0 est en version candidate depuis 2023.

Checklist

  • PHPStan au niveau 10, avec les extensions Symfony et Doctrine.
  • Une baseline commitée, qui ne grossit pas sans revue.
  • PHP-CS-Fixer en correction locale et en vérification en CI.
  • Rector en --dry-run en CI, et pour chaque montée de version.
  • Des couches Deptrac, avec des entités qui ne dépendent de rien.
  • lint:yaml, lint:container et doctrine:schema:validate --skip-sync à chaque pull request.
  • composer audit --locked, hors cache, à chaque CI.
  • Les mêmes tâches moon en pre-commit et en CI, obligatoires avant la fusion.
  • Un rapport périodique sur main : PhpMetrics, et Community Build s'il est en place.

Sources

Versions et dates relevées sur Packagist et GitHub le 1er octobre 2026. Dernières versions stables : PHPStan 2.2.16, PHP-CS-Fixer 3.95.27, Rector 2.6.7, Deptrac 4.7.2, Psalm 6.19.1 (7.0 en bêta), Mago 1.50.0.