Aller au contenu principal

jakzal/phpqa : sortir la QA Symfony de son vendor/.

Découvrez jakzal/phpqa, une image Docker pour exécuter des outils de qualité de code en PHP sans les installer dans votre projet. Finis les conflits de dépendances et les paquets inutiles en production.

MAJ 6 min de lecture
Sommaire · 10


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 :

JSON
"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 update recalcule 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 audit voit 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 :alpine rolling). La QA est reproductible dev ↔ CI à la version PHP-mineure près.
  • Cache /tmp partagé (var/tmp-phpqa:/tmp). PHPStan met sa resultCache.php dedans, 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 en root et 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 || true

Et 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:container

composer 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.neon dans la config — ces chemins n'existent pas dans le projet, les binaires QA vivent dans le container, pas dans vendor/.

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.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.