J'ai décidé supprimer un fichier de presque 3 000 lignes que j'avais écrit moi-même, cible après cible, pendant un an et demi. C'était le Makefile de ce site. 176 cibles, des sections commentées en ASCII art, des variables PHPQA_IMAGE recopiées à trois endroits.
En le démontant, j'ai trouvé des cibles mortes depuis des mois. redis-cli pointait un service redis supprimé quand j'ai séparé le cache et l'état en deux instances. perf-audit-pg visait un hôte Postgres qui n'existait plus dans le compose. Le Makefile du site était devenu une carotte glaciaire : chaque strate datait d'un chantier, et plus jamais on ne redescendait.
Ce billet fait deux choses. Il monte un castor.php de zéro, tâche par tâche, jusqu'à une suite QA qui tourne en parallèle. Et il raconte ce que la documentation de Castor ne dit pas : ce que coûte vraiment une migration depuis Make, et les trois pièges qui m'ont mordu en route.
Ce que Castor fait, en une page
Castor est un task runner écrit par JoliCode. Vos tâches d'automatisation sont des fonctions PHP annotées, pas des recettes Make ni des scripts Bash.
Trois propriétés le distinguent d'un Makefile :
Sur le même sujet
Composer : le manifeste du développeur moderne
- Il se distribue en binaire statique avec son propre PHP embarqué. Aucune dépendance dans votre
composer.json, aucune version de PHP hôte à satisfaire. - Les tâches sont déclarées par attribut (
#[AsTask]), découvertes par introspection, et groupées par namespace PHP. - Vous récupérez tout PHP au passage : typage, exceptions,
try/catch, autocomplétion IDE, et PHPStan qui analyse vos tâches comme le reste du code.
Ce dernier point n'est pas cosmétique. Sur ce blog, les fichiers de tâches passent au niveau max de PHPStan, dans la même commande que src/. Une faute de frappe dans un nom de service Docker se voit avant l'exécution, pas pendant.
Installer le binaire (et le piège du PATH)
Deux chemins d'installation, et ils ne se valent pas.
# Recommandé : binaire statique, PHP embarqué, rien d'autre à installer
curl "https://castor.jolicode.com/install" | bash -s -- --static
# Variante phar : dépend du PHP de la machine hôte
curl "https://castor.jolicode.com/install" | bashLe --static compte. Sans lui, vous récupérez un .phar qui s'exécute avec le PHP de l'hôte. Sur une machine où PHP est volontairement absent (stack 100 % Docker, ce qui est mon cas), le phar ne démarre pas.
L'installeur pose le binaire dans ~/.local/bin. Retenez ce chemin, on y revient au dernier piège.
Côté projet, une seule ligne protège tout le monde :
<?php
declare(strict_types=1);
use function Castor\guard_min_version;
use function Castor\import;
guard_min_version('1.6.0');
import(__DIR__ . '/.castor');guard_min_version() fait échouer Castor avec un message clair si le binaire est trop vieux, au lieu de laisser une erreur de parsing incompréhensible remonter depuis un attribut inconnu. Sur un projet à plusieurs, c'est la différence entre « ça marche pas » et « mets à jour ton castor ».
Première tâche
Une tâche, c'est une fonction annotée. Rien de plus.
<?php
namespace stack;
use Castor\Attribute\AsTask;
use function Castor\io;
use function Castor\run;
#[AsTask(name: 'up', namespace: '', description: 'Démarre l\'environnement de dev')]
function up(): void
{
io()->title('Démarrage de la stack');
run(['docker', 'compose', 'up', '-d', '--wait']);
io()->success('Stack prête.');
}io() renvoie un SymfonyStyle, exactement celui des commandes Symfony Console. Vous avez title(), section(), success(), warning(), error(), table(), confirm(), ask() et les barres de progression, sans rien câbler.
Le namespace: '' dans l'attribut mérite une explication. Le fichier vit dans le namespace PHP stack, donc la tâche s'appellerait stack:up par défaut. Le forcer à vide expose castor up, la commande courte que je tape vingt fois par jour.
run() ou capture() : la différence qui compte
Deux fonctions, deux usages, et le choix se fait sur une question simple : qui lit la sortie ?
use function Castor\capture;
use function Castor\context;
use function Castor\run;
// L'humain lit : sortie en direct, exception si le code retour n'est pas 0
run(['docker', 'compose', 'ps']);
// Le code lit : sortie silencieuse, retournée en string
$branch = capture(['git', 'rev-parse', '--abbrev-ref', 'HEAD']);
// Une commande qui a le droit d'échouer
run(['docker', 'image', 'inspect', $image], context: context()->withAllowFailure());
// Une commande interactive (shell, psql, redis-cli)
run(['docker', 'compose', 'exec', 'php', 'bash'], context: context()->toInteractive());Le Context se compose : context()->withQuiet()->withAllowFailure()->withTimeout(null). Le withTimeout(null) sert dès qu'une tâche suit des logs ou ouvre un shell, sinon Castor coupe au bout de 60 secondes.
Un détail qui m'a coûté une demi-heure : capture() n'a pas d'option allowFailure. Vous cherchez onFailure:, qui prend une valeur de repli.
Organiser : castor.php plus .castor/
C'est ici que Castor décroche vraiment de Make. Un Makefile, c'est un fichier. Un projet Castor, c'est une arborescence.
L'import() du point d'entrée charge récursivement tout le dossier. Chaque fichier déclare son namespace PHP, et ce namespace devient le préfixe des tâches : .castor/qa.php en namespace qa expose qa:stan, qa:cs, qa:deptrac.
Les fonctions sans #[AsTask] restent des helpers privés au projet. Le fichier docker.php ci-dessous n'expose aucune tâche : il ne contient que des wrappers et des constantes, consommés par tous les autres.
castor.php
<?php
declare(strict_types=1);
/*
* Point d'entrée du task runner. Castor tourne sur l'HÔTE (binaire statique,
* PHP embarqué) et orchestre docker compose de l'extérieur : le PHP applicatif,
* lui, ne quitte jamais le container.
*
* castor list — toutes les tâches, groupées par namespace
*/
use function Castor\guard_min_version;
use function Castor\import;
guard_min_version('1.6.0');
// Charge récursivement tout .castor/ : un fichier par domaine.
import(__DIR__ . '/.castor');
.castor/stack.php
<?php
declare(strict_types=1);
namespace stack;
use Castor\Attribute\AsRawTokens;
use Castor\Attribute\AsTask;
use function Castor\context;
use function Castor\io;
#[AsTask(name: 'up', namespace: '', description: 'Démarre l\'environnement de dev')]
function up(): void
{
io()->title('Démarrage de la stack');
compose(['up', '-d', '--wait']);
io()->success('Stack prête.');
}
#[AsTask(name: 'down', namespace: '', description: 'Arrête l\'environnement de dev')]
function down(): void
{
compose(['down']);
}
#[AsTask(name: 'sh', namespace: '', description: 'Shell dans le container php')]
function sh(): int
{
return php_exec(['bash'], c: context()->withTimeout(null)->toInteractive())
->getExitCode() ?? 0;
}
/**
* Passthrough vers bin/console. #[AsRawTokens] transmet les jetons tels quels,
* options comprises. Une option en PREMIER jeton exige un `--` explicite :
* `castor console -- --version`.
*
* @param list<string> $tokens
*/
#[AsTask(name: 'console', namespace: '', description: 'Commande Symfony console')]
function console(#[AsRawTokens] array $tokens = []): int
{
return bin_console([] === $tokens ? ['list'] : $tokens)->getExitCode() ?? 0;
}
.castor/docker.php
<?php
declare(strict_types=1);
/*
* Wrappers Docker + pins d'images. AUCUN #[AsTask] ici : ce fichier n'expose
* pas de commande, il fournit les briques que les autres consomment.
*
* Toutes les invocations run() sont en FORME ARRAY dès qu'une variable entre
* dans la commande — la forme string n'échappe rien.
*/
use Castor\Context;
use Symfony\Component\Process\Process;
use function Castor\context;
use function Castor\exit_code;
use function Castor\io;
use function Castor\run;
// Pin suivi par Renovate (customManager castor, datasource docker).
const PHPQA_IMAGE = 'jakzal/phpqa:1.123.3-php8.5-alpine';
/** @param list<string> $args */
function compose(array $args, ?Context $c = null): Process
{
return run(
['docker', 'compose', '--env-file', '.env.local', '-p', compose_project(), ...$args],
context: $c,
);
}
/** @param list<string> $cmd */
function php_exec(array $cmd, ?Context $c = null): Process
{
return compose(['exec', '-T', 'php', ...$cmd], $c);
}
/**
* Build paresseux d'une image maison : on ne reconstruit que si elle manque.
* NON réentrant — à appeler AVANT un parallel(), jamais dedans.
*
* @param array<string, string> $buildArgs
*/
function ensure_image(string $image, string $buildDir, array $buildArgs = []): void
{
if (0 === exit_code(['docker', 'image', 'inspect', $image], context: context()->withQuiet())) {
return;
}
io()->note(\sprintf('Building %s (one-shot)…', $image));
$cmd = ['docker', 'build', '--quiet'];
foreach ($buildArgs as $name => $value) {
$cmd[] = '--build-arg';
$cmd[] = \sprintf('%s=%s', $name, $value);
}
run([...$cmd, '-t', $image, $buildDir]);
}
Le piège de la forme string
Castor accepte deux formes pour run(), et une seule est sûre.
// Forme array : chaque argument est échappé par Symfony Process
run(['docker', 'compose', 'exec', 'php', 'bin/console', $command]);
// Forme string : passée telle quelle au shell
run("docker compose exec php bin/console {$command}");Tant que la commande est un littéral, la forme string est lisible et sans danger. Dès qu'une variable entre dedans, elle devient une injection shell en puissance. Un slug de billet contenant une apostrophe, un chemin avec un espace, et votre tâche exécute autre chose que ce que vous croyez.
La règle que j'applique désormais sans exception : une variable dans la commande, c'est la forme array. Elle est plus verbeuse, elle ne se relit pas aussi bien, elle ne se trompe jamais.
parallel() : ce que make -j ne sait pas faire
Make sait paralléliser avec -j. Il parallélise le graphe de dépendances, pas ce que vous voulez.
Sur ce blog, la QA lance douze outils : PHPStan, PHP-CS-Fixer, Twig-CS-Fixer, Deptrac, Rector, Biome, Stylelint, Knip, Ansible, composer audit, le lint du container et un boot du container de prod. Certains sont indépendants. Trois ne le sont pas : ils touchent le même container et le même dossier var/cache/prod, donc les chevaucher les fait se marcher dessus.
Pour aller plus loin
jakzal/phpqa : sortir la QA Symfony de son vendor/
Avec make -j, exprimer « ces trois-là en série, tout le reste en parallèle » demande des cibles intermédiaires et des dépendances artificielles. Avec Castor, c'est la structure d'un tableau : chaque entrée est une voie d'exécution, et l'ordre à l'intérieur d'une voie est séquentiel par construction.
Étape 1/8
Le nom long, l'alias court
qa:all et lui donne l'alias qa. Les deux fonctionnent en ligne de commande. Retenez cet alias : c'est lui qui piège les scripts de vérification, on y revient plus bas.Étape 2/8
Une option typée, pas une variable d'environnement
#[AsOption] transforme un paramètre de fonction en option CLI. castor qa --sequential passe true. En Make, il aurait fallu une variable SEQ=1 lue dans la recette, sans typage ni auto-complétion.Étape 3/8
Le pré-warm des images
parallel(), deux fibers qui constatent l'absence de l'image la construiraient en double. On les force donc avant le fan-out.Étape 4/8
Une voie d'exécution par entrée
nom de tâche => code de sortie, ce qui permet d'agréger les résultats sans variable partagée.Étape 5/8
La voie qui doit rester en série
var/cache/prod. Les mettre dans la même closure les rend séquentielles par construction. Aucune dépendance artificielle à déclarer, juste un choix de regroupement.Étape 6/8
Parallèle ou pas, même code
parallel() exécute les closures en concurrence, array_map() les déroule en série. Le reste de la fonction ignore lequel des deux a tourné. Le mode séquentiel sert au debug, quand deux sorties entrelacées deviennent illisibles.Étape 7/8
Agréger, ne pas s'arrêter au premier échec
Étape 8/8
Un seul code de sortie pour le hook
int au lieu de jeter une exception. C'est ce qui permet d'écrire castor qa || fail=1 dans le hook pre-push sans traiter de stack trace.#[AsTask(name: 'all', namespace: 'qa', aliases: ['qa'], description: 'QA bloquante (PHP + front + Ansible)')]
function all(
#[AsOption(description: 'Exécution séquentielle (debug, machine chargée)')]
bool $sequential = false,
): int {
// Pré-warm : ensure_image() n'est pas réentrant sous parallel().
ensure_frontqa_image();
ensure_ansibleqa_image();
/** @var list<callable(): array<string, int>> $lanes */
$lanes = [
fn(): array => ['qa:stan' => stan()],
fn(): array => ['qa:cs' => cs()],
fn(): array => ['qa:twig' => twig()],
fn(): array => ['qa:deptrac' => deptrac()],
fn(): array => ['qa:rector' => rector()],
fn(): array => ['qa:biome' => biome(), 'qa:stylelint' => stylelint(), 'qa:knip' => knip()],
fn(): array => ['qa:ansible' => ansible()],
// Voie container php : séquentielle par construction.
fn(): array => ['qa:security' => security(), 'qa:container' => container(), 'qa:prod-boot' => prod_boot()],
];
$results = $sequential
? array_map(static fn(callable $lane): array => $lane(), $lanes)
: parallel(...$lanes);
$failed = [];
foreach ($results as $lane) {
foreach ($lane as $task => $code) {
if (0 !== $code) {
$failed[] = $task;
}
}
}
if ([] !== $failed) {
io()->error(\sprintf('QA en échec : %s', implode(', ', $failed)));
return 1;
}
io()->success('All quality checks passed.');
return 0;
}
Migrer depuis un Makefile : la méthode du shim
Voilà ce que la documentation ne raconte pas, parce que ce n'est pas son travail.
Une migration Make vers Castor ne se joue pas sur la traduction des recettes. Ça, c'est mécanique, et c'est la partie facile. Elle se joue sur tout ce qui appelle vos cibles : les hooks git, la CI, les scripts, la doc, les README, les commentaires de configuration. Sur ce blog, c'était environ 450 occurrences de make <cible> réparties sur 158 fichiers.
Le piège, c'est le hook pre-push. Il lance la suite QA avant chaque push. Tant qu'il appelle make qa, supprimer le Makefile bloque tous vos pushes, et vous ne pouvez plus pousser le commit qui répare le hook.
La sortie tient en trois temps :
- Un shim. Un Makefile de 90 lignes avec une règle attrape-tout qui traduit
make <cible>encastor <tâche>, affiche un avertissement de dépréciation, et laisse tout continuer de fonctionner. - Une preuve de parité. Un fichier TSV qui mappe chaque cible de l'ancien Makefile vers sa tâche Castor, plus un script qui vérifie en CI que rien n'a été oublié et que chaque tâche mappée existe vraiment dans
castor list. 158 cibles ont migré, 17 ont été abandonnées, chacune avec sa raison écrite en clair dans le mapping. - La suppression. Une fois les appels propagés et les hooks basculés, le shim et sa preuve partent ensemble.
Les 17 cibles abandonnées sont les fossiles de la carotte glaciaire : des alias qui doublonnaient (phpstan renvoyait à qa-stan), des cibles internes que Castor remplace par une fonction, et un linter de Makefile devenu sans objet.
Le diff ci-dessous montre la bascule du hook lui-même, celle qui doit arriver en dernier. Les trois appels changent de nom, et le binaire passe par une variable : c'est ce qui permet à la garde du troisième piège de le résoudre quand le PATH est incomplet.
supprimé : −echo "▶ pre-push : gate CI local (make qa + test-unit + test-front)…"
ajouté : +echo "▶ pre-push : gate CI local (castor qa + test:unit + test:front)…"
echo " (lourd — bypass ponctuel : git push --no-verify)"
fail=0
supprimé : −make qa || fail=1
supprimé : −make test-unit || fail=1
supprimé : −make test-front || fail=1
ajouté : +"$CASTOR" qa || fail=1
ajouté : +"$CASTOR" test:unit || fail=1
ajouté : +"$CASTOR" test:front || fail=1
if [ "$fail" -ne 0 ]; then
echo
echo "✗ Gate KO — push BLOQUÉ (la CI aurait échoué)."
supprimé : − echo " Corrige (make qa-fix / qa-front-fix pour le style), puis re-push."
ajouté : + echo " Corrige (castor qa:fix / castor qa:front-fix pour le style), puis re-push."
echo " Bypass ponctuel : git push --no-verify"
exit 1
fi
Trois pièges que la documentation ne mentionne pas
Les alias plats sont invisibles des listes
Castor permet de donner un alias court à une tâche : qa:all répond aussi à castor qa. Pratique, et piégeux dès qu'un script vérifie qu'une tâche existe.
# Aucune de ces deux commandes ne connaît l'alias `qa`
castor list --raw | awk '{print $1}' | grep -x qa
castor list --format=json | jq -r '.commands[].name' | grep -x qa
# Les alias vivent dans le tableau `usage`, jamais dans `name`
castor list --format=json \
| jq -r '.commands[] | .name, (.usage[]? | split(" ")[0])' \
| sort -uJ'ai un audit de documentation qui vérifie que chaque commande citée dans le README existe pour de vrai. Il m'a signalé castor qa, castor deploy et castor migrate comme inexistantes, c'est-à-dire les trois commandes les plus citées de toute ma doc. L'union des name et des premiers jetons de usage règle le problème.
Le passthrough avale les options de tête
Pour transmettre des arguments bruts à une commande enveloppée, Castor fournit l'attribut #[AsRawTokens] :
#[AsTask(name: 'console', namespace: '', description: 'Commande Symfony console')]
function console(#[AsRawTokens] array $tokens = []): int
{
return bin_console([] === $tokens ? ['list'] : $tokens)->getExitCode() ?? 0;
}castor console cache:clear --env=prod fonctionne, options comprises. Mais si le premier jeton commence par un tiret, Castor le consomme pour lui-même :
castor console --version # affiche castor v1.6.1, exit 0, aucune erreur
castor console -- --version # affiche la version de SymfonyC'est l'échec le plus vicieux du lot, parce qu'il est silencieux et qu'il retourne 0. Le -- explicite dès qu'une option ouvre la commande.
Le PATH minimal des hooks lancés par une interface graphique
make vit dans /usr/bin. Castor vit dans ~/.local/bin. Un hook git lancé depuis votre terminal trouve les deux. Un hook lancé par un client git graphique reçoit un PATH minimal qui contient le premier et pas le second.
Résultat : un hook qui bascule de make à castor sans précaution passe d'un échec bruyant à un skip silencieux. La garde tient en quatre lignes :
CASTOR="$(command -v castor 2>/dev/null || true)"
if [ -z "$CASTOR" ] && [ -x "$HOME/.local/bin/castor" ]; then
CASTOR="$HOME/.local/bin/castor"
fiEt si vraiment castor est introuvable, on saute le gate au lieu de bloquer le commit, comme on le fait déjà quand Docker est éteint. La CI reste le filet.
Castor face aux autres
| Critère | Make | Scripts Composer | Commande Symfony | Castor |
|---|---|---|---|---|
| Installation | présent partout | déjà là | déjà là | binaire à installer |
| Langage | recettes plus shell | JSON plus shell | PHP | PHP |
| Analyse statique | aucune | aucune | oui | oui |
| Tourne sans l'app | oui | non | non | oui |
| Parallélisme | par le graphe | non | non | explicite |
| Interactivité | shell brut | shell brut | SymfonyStyle | SymfonyStyle |
| Lisible hors PHP | oui | moyennement | non | non |
La ligne qui décide, c'est « tourne sans l'app ». Une commande Symfony a besoin d'un container qui boote, d'un .env valide et d'un kernel qui compile. C'est exactement ce dont vous ne disposez pas quand vous voulez justement construire l'image, restaurer la base ou réparer un container cassé.
Castor tourne sur l'hôte, avec son PHP à lui, et orchestre Docker de l'extérieur. Le PHP applicatif reste dans le container. Les deux ne se croisent jamais.
Ce que Castor ne résout pas
Le coût du démarrage. Chaque invocation démarre un runtime PHP. Sur de l'orchestration (lancer Docker, jouer des migrations, appeler un linter), c'est négligeable face au temps des commandes appelées. Sur une boucle serrée qui traite dix mille fichiers, Bash ou un binaire compilé gagnent.
La dépendance au langage. Un Makefile se lit par n'importe quel développeur, quelle que soit sa stack. Un fichier Castor suppose de lire du PHP moderne : attributs, readonly, types union. Sur un projet Symfony, c'est acquis. Dans une équipe mixte avec des ops qui vivent en Bash, c'est un vrai sujet.
La portabilité des commandes appelées. Vos tâches sont en PHP, mais elles exécutent docker, ssh, pg_dump, jq. Castor ne rend pas ces binaires portables, il ne fait que les appeler proprement.
L'âge de l'écosystème. Make a cinquante ans de documentation, de questions Stack Overflow et de réponses. Castor est jeune. Sa doc couvre les cas courants et son code source est lisible, mais vous serez parfois le premier à rencontrer votre problème.
L'outillage est du code comme le reste
Trois jours de migration pour un fichier que je croyais connaître, et l'inventaire a produit trois cibles cassées, dix-sept fossiles et une variable de version recopiée à trois endroits qui avaient silencieusement divergé.
Le Makefile n'avait rien fait de mal. Il avait juste grossi sans que rien ne le mesure, parce qu'un task runner ne casse jamais la production. Il casse une commande qu'un seul développeur lance, un jour sur trente, en soupirant. C'est la définition même du code qu'on ne relit pas.
Castor ne corrige pas ça tout seul. Il rend simplement l'outillage justiciable des mêmes contrôles que le reste : analyse statique, style, tests, revue. La bascule ne tient pas à écrire ses tâches en PHP. Elle tient à cesser de considérer que le fichier qui construit votre projet mérite moins d'attention que le projet.
Et vous, votre Makefile, il fait combien de lignes ? Vous savez ce que fait sa cible la plus ancienne ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).