En dev, je voulais le strict minimum dans composer.json et déléguer la QA à un container dédié. PHPStan, Rector, PHP-CS-Fixer, Twig-CS-Fixer, Deptrac n'ont aucune raison de vivre dans le vendor/ qui part en prod. Ce sont des outils invocables ponctuellement, pas du runtime. La réponse est jakzal/phpqa, une image Docker qui empile les binaires QA en .phar et qu'on instancie en one-shot.
Ce billet raconte la migration et les trois cas qui ne rentrent pas dans l'image. Ce sont eux qui font hésiter avant de basculer.
Le constat avant migration
composer.json de mon site, section require-dev, avant :
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"friendsofphp/php-cs-fixer": "^3.65",
"vincentlanglet/twig-cs-fixer": "^3.5",
"qossmic/deptrac": "^2.0",
"rector/rector": "^2.0",
…
}10 paquets liés à la QA. Avec leurs transitives, ~120 entrées dans composer.lock sont dédiées à des outils qui ne tournent jamais en runtime. Résultat :
- chaque
composer updaterecalcule un graphe énorme pour des libs qu'on ne charge pas en HTTP, - les conflits de version transitives (PHPStan vs Rector vs Symfony 8.1) ralentissent les upgrades,
- la version utilisée en local diverge silencieusement de la CI dès que le lock file dérive,
composer auditvoit du bruit dans les advisories sur du code qu'on ne déploie pas.
Le constat est mécanique : un outil de QA n'a aucune raison d'être dans le vendor/ qui part en prod.
jakzal/phpqa : un container, tous les outils
jakzal/phpqa est une image Docker maintenue depuis 2017 qui regroupe les binaires QA PHP installés via phive — donc en .phar, sans pollution Composer. Tag 1.122.2-php8.5-alpine au moment où j'écris : PHPStan 2.x, PHP-CS-Fixer 3.x, Twig-CS-Fixer 3.x, Rector 2.x, Deptrac 2.x, Infection, Composer-Unused, Composer-Require-Checker, Deprecation Detector, le tout dans un environnement PHP 8.5.
L'image est invocable, pas runnable : on l'instancie en one-shot pour chaque commande, le projet est monté dedans, le binaire tourne, le container meurt.
PHPQA_IMAGE := jakzal/phpqa:1.122.2-php8.5-alpine
PHPQA := docker run --init --rm \
-v $(PWD):/project \
-v $(PWD)/var/tmp-phpqa:/tmp \
-w /project \
--user $(shell id -u):$(shell id -g) \
$(PHPQA_IMAGE)Trois choix qui paient sur la durée :
- Tag pinné (
1.122.2-php8.5-alpine, pas:alpinerolling). La QA est reproductible dev ↔ CI à la version PHP-mineure près. - Cache
/tmppartagé (var/tmp-phpqa:/tmp). PHPStan met saresultCache.phpdedans, ce qui rend les runs successifs ~10× plus rapides. On le réutilise entre toutes les invocations. --user $(id -u):$(id -g). Sans ça, les fichiers générés par PHPStan/Rector ressortent enrootet le poste dev ne peut plus les supprimer.
Cibles atomiques + une cible lente
Le Makefile expose deux niveaux : un rapide qui tourne avant chaque commit (gating), et un lent qui tourne avant chaque release (informatif).
qa: ## QA: blocking checks (PHPStan, PHP-CS, Twig-CS, deptrac, security, container, biome)
@mkdir -p var/tmp-phpqa
@failed=0; \
$(PHPQA) phpstan analyse --memory-limit=-1 --no-progress || failed=1; \
$(PHPQA) php-cs-fixer fix --dry-run --diff || failed=1; \
$(PHPQA) twig-cs-fixer lint || failed=1; \
$(PHPQA) deptrac analyse --no-progress || failed=1; \
$(COMPOSER) audit --format=plain || failed=1; \
$(PHPQA) rector process --dry-run --no-progress-bar || failed=1; \
$(CONSOLE) lint:container || failed=1; \
bin/biome check assets/ || failed=1; \
exit $$failed
qa-deep: qa qa-require qa-unused qa-deprecated qa-unused-public qa-jscpd
@$(PHPQA) infection --min-msi=70 --threads=4 --no-progress || trueEt chaque outil a sa cible isolée (qa-stan, qa-cs, qa-twig, qa-deptrac, qa-rector) — appelables individuellement par les hooks éditeur ou les subagents Claude qui ne veulent pas re-lancer la suite complète.
Quelques cas qui ne rentrent pas dans jakzal/phpqa
Container lint (lint:container)
bin/console lint:container a besoin du container Symfony booté — donc du framework, des bundles, des services compilés. Aucune image one-shot ne peut faire ça à la place du runtime PHP de l'app. La cible reste donc dans la stack FrankenPHP :
qa-container:
@$(CONSOLE) lint:container # = docker compose exec php bin/console lint:containercomposer audit
Le audit lit le composer.lock du projet et tape les advisories de Packagist. Lui aussi a besoin du PHP de l'app pour résoudre les classes dynamiquement, et du composer.json réel.
Biome (JS/TS)
Biome est un binaire natif Rust, pas une lib PHP. Il vit dans bin/biome (~30 MB), shippé par le bundle Composer kocal/biome-js-bundle. Pas dans jakzal — l'image est PHP-only.
Attention au parser PHP des outils non maintenus
Toutes les libs PHP qui parsent du code source pour faire de l'analyse embarquent leur propre version de nikic/php-parser. Si la lib n'est pas maintenue, son parser bloque sur les nouveautés langage.
Sur PHP 8.5 :
- PHPMetrics 2.9.0 : fatal sur l'opérateur pipe
|>(PHP 8.5). - PDepend 2.16.2 : fatal sur la chaîne
new \ReflectionClass($x)->method()(PHP 8.4 « new in initializers »).
Les deux sont packagés dans jakzal/phpqa, mais inutilisables sur du PHP > 8.3 réel. C'est documenté dans le Makefile :
# Rapport de complexité (phpmetrics / pdepend) : DÉSACTIVÉ.
# Les deux outils embarquent un parser PHP qui ne reconnaît pas les nouveautés
# PHP 8.4+/8.5 (pipe |>, new ...->method()).
# À réactiver quand un mainteneur reprend la lib OU quand on bascule sur un
# remplaçant maintenu (PHPStan + extensions complexity, ou shipmonk/phpstan-rules).PHPStan, Rector, PHP-CS-Fixer suivent PHP en quasi-temps réel — ils ne sont pas concernés. C'est uniquement la couche « métriques de complexité » qui est devenue orpheline.
Le wiring du projet
Tout tient dans trois fichiers : Makefile (commenté), phpstan.neon (config standalone, plus de référence à vendor/phpstan-…/extension.neon), et un var/tmp-phpqa/ ajouté au .gitignore. La règle qui m'a fait gagner du temps, posée en commentaire dans phpstan.neon parce que je m'étais déjà fait avoir une fois :
Ne JAMAIS mentionner
vendor/phpstan-*/extension.neondans la config — ces chemins n'existent pas dans le projet, les binaires QA vivent dans le container, pas dansvendor/.
Le mot de la fin
L'écosystème PHP a considéré pendant longtemps que les outils QA et le code applicatif partageaient le même vendor/. C'est un héritage de l'époque où Composer était le seul gestionnaire de dépendances envisageable, où Docker n'existait pas en dev, et où la CI tournait dans la même JVM que le code. Aujourd'hui, c'est un anti-pattern : on charge en mémoire des libs qui n'ont aucune raison d'y être, on prend des conflits de version sur du code qui ne part même pas en prod, et on rend la suite QA dépendante de la version PHP du projet alors qu'elle pourrait tourner sur n'importe quelle PHP supportée par l'outil.