Aller au contenu principal

Castor : un task runner en PHP pour les projets Symfony

Castor permet d'écrire des tâches d'automatisation en PHP au lieu de Bash ou Make. Orchestration Docker, synchronisation de base, QA et déploiement dans un projet Symfony.
Catégorie

DX

Marre des environnements de dev frustrants et des outils qui ralentissent votre workflow ? Améliorez votre quotidien avec des méthodes qui remettent enfin le plaisir au cœur du code.

Lecture
8 min
Niveau
Intermédiaire
déc 20 2025
Partager

Castor est un task runner développé par JoliCode. Il permet d'écrire des tâches d'automatisation en PHP au lieu de Bash ou Make. L'outil se distribue sous forme de binaire autonome (aucune dépendance Composer) et utilise les attributs PHP 8 pour déclarer les tâches. Cet article présente son fonctionnement, ses cas d'usage dans un projet Symfony, et ses limites.

Installation et premier contact

Castor se distribue via un script d'installation ou Homebrew :

Bash
# Via le script officiel
curl -Ls https://castor.jolicode.com/install | bash

# Via Homebrew (macOS/Linux)
brew install castor

Une tâche Castor est une fonction PHP annotée avec #[AsTask] :

PHP
use Castor\Attribute\AsTask;
use function Castor\io;

#[AsTask(description: 'Affiche un message de test')]
function hello(): void
{
    io()->success('Hello from Castor!');
}

L'exécution se fait via la commande castor suivie du nom de la tâche :

Bash
$ castor hello
 [OK] Hello from Castor!

La fonction io() retourne une instance de SymfonyStyle, le même helper que celui utilisé dans les commandes Symfony Console. Les méthodes success(), error(), warning(), table(), confirm(), ask() et section() sont disponibles.

Exécution de commandes : run() et capture()

Castor fournit deux fonctions pour exécuter des commandes système.

run() exécute une commande et affiche sa sortie en temps réel. Si la commande échoue (code de retour non-zéro), une exception est levée :

PHP
use function Castor\run;

// Exécute et affiche la sortie
run('docker compose ps');

capture() exécute une commande silencieusement et retourne sa sortie sous forme de chaîne :

PHP
use function Castor\capture;

// Exécute silencieusement, retourne la sortie
$branch = capture('git rev-parse --abbrev-ref HEAD');
io()->note("Branche courante : {$branch}");

La fonction context() modifie le comportement de l'exécution :

PHP
use function Castor\context;

// Ignorer les erreurs (pas d'exception si code retour != 0)
run('docker compose exec php composer install', context: context()->withAllowFailure());

// Exécuter sans afficher la sortie
run('docker compose exec php curl -sf http://localhost/', context: context()->withQuiet());

// Mode TTY pour les commandes interactives
run('docker compose exec php sh', context: context()->withTty());

// Combiner les options
run('command', context: context()->withQuiet()->withAllowFailure());

La différence entre run() et capture() détermine le cas d'usage : run() pour les commandes dont l'utilisateur doit voir la sortie (builds, migrations), capture() pour les commandes dont le résultat est consommé par le code PHP (vérifications, extraction de valeurs).

Cas d'usage : démarrage d'un environnement Docker

Un cas concret illustre l'intérêt d'écrire l'automatisation en PHP. Le démarrage d'un environnement Docker nécessite plusieurs étapes séquentielles, dont certaines dépendent du résultat des précédentes.

Approche Bash classique :

Bash
docker compose up -d --build
sleep 10  # durée arbitraire
composer install
php bin/console doctrine:migrations:migrate --no-interaction

Le sleep 10 est une attente arbitraire. Si le service met plus de 10 secondes à démarrer, les commandes suivantes échouent. S'il met 2 secondes, on attend 8 secondes pour rien.

Approche Castor avec attente active :

PHP
#[AsTask(description: 'Start the development environment', aliases: ['start'])]
function start(): void
{
    io()->title('Starting the development environment...');

    io()->section('0. Setting up local secrets...');
    setup_local_secrets();

    io()->section('1. Building and starting all Docker services...');
    run('docker compose up -d --build');

    // Brief pause for containers to initialize
    sleep(3);

    io()->section('2. Installing Composer dependencies...');
    run('docker compose exec php composer install --no-interaction', context: context()->withAllowFailure());

    io()->section('3. Installing importmaps...');
    run('docker compose exec php php bin/console importmap:install', context: context()->withAllowFailure());

    io()->section('4. Building Tailwind assets...');
    io()->note('Tailwind assets will be built automatically by the Symfony worker in watch mode.');

    io()->section('5. Running database migrations...');
    run('docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction', context: context()->withAllowFailure());

    io()->section('6. Indexing Meilisearch...');
    run('docker compose exec php php bin/console app:search:index', context: context()->withAllowFailure());

    io()->success('Environment is ready.');
    io()->table(
        ['Service', 'URL'],
        [
            ['Application', 'https://lecodeestdanslepre.local'],
            ['PostgreSQL', 'localhost:5432'],
            ['Redis', 'localhost:6379'],
        ],
    );
}

La boucle for tente une connexion HTTP toutes les secondes pendant 60 secondes maximum. Si le service répond, la boucle s'interrompt et les étapes suivantes s'exécutent. Si le timeout est atteint, un avertissement est affiché. Les étapes Composer et migrations utilisent withAllowFailure() pour ne pas bloquer l'ensemble du processus en cas d'erreur non critique.

L'équivalent en Bash nécessiterait une boucle while avec curl, la gestion des codes de retour via $?, et des conditions if [ ... ]. C'est faisable, mais la gestion d'erreurs et le feedback utilisateur (sections, tableaux, messages colorés) demandent plus de code.

Cas d'usage : synchronisation de base de données

La synchronisation d'une base de production vers l'environnement local est une opération destructrice. Castor permet d'ajouter une confirmation explicite avant l'exécution :

PHP
#[AsTask(name: 'sync', description: 'Sync database from production to local')]
function sync_from_prod(): void
{
    io()->title('Database sync from production');
    io()->warning([
        'This will OVERWRITE all local development data.',
        'Production data will be copied to your local environment.',
    ]);

    if (!io()->confirm('Confirm sync from production?', false)) {
        io()->note('Sync cancelled.');

        return;
    }

    $dumpFile = '/tmp/prod-sync-' . date('Ymd-His') . '.sql';

    try {
        io()->section('1. Creating dump from production database');
        run(
            'ssh ' . DEPLOY_HOST . ' "cd ' . DEPLOY_DIR
            . ' && docker compose exec -T database pg_dump -U app app" > ' . $dumpFile,
        );

        io()->section('2. Creating local backup before sync');
        $backupFile = 'data/backups/dev-backup-' . date('Ymd-His') . '.sql';
        run(
            "docker compose exec -T database pg_dump -U dev dev > {$backupFile}",
            context: context()->withAllowFailure(),
        );
        io()->note("Local backup saved: {$backupFile}");

        io()->section('3. Importing production dump to local');
        run("docker compose exec -T database psql -U dev -d dev < {$dumpFile}");

        io()->success('Database sync completed.');
    } catch (\Throwable $e) {
        io()->error('Database sync failed: ' . $e->getMessage());

        throw $e;
    }
}

Le try/catch PHP gère les erreurs de manière explicite. Si le dump distant échoue, l'import local ne s'exécute pas. Le backup local avant l'import utilise withAllowFailure() car l'absence de backup ne doit pas bloquer la synchronisation.

Organisation des tâches avec les namespaces

Castor utilise les namespaces PHP pour grouper les tâches par domaine :

PHP
// .castor/infra.php
namespace infra;

use Castor\Attribute\AsTask;
use function Castor\run;

#[AsTask(description: 'Start environment', aliases: ['start'])]
function start(): void { /* ... */ }

#[AsTask(description: 'Stop environment', aliases: ['stop'])]
function stop(): void
{
    run('docker compose down');
}
PHP
// .castor/qa.php
namespace qa;

use Castor\Attribute\AsTask;
use function Castor\run;

#[AsTask(description: 'Run PHPStan')]
function phpstan(): void
{
    run('docker compose exec php vendor/bin/phpstan analyse');
}

#[AsTask(name: 'cs-fix', description: 'Fix PHP code style')]
function cs_fix(): void
{
    run('docker compose exec php vendor/bin/php-cs-fixer fix');
}

Le résultat dans le terminal :

Available commands:
  start                   Start environment (alias)
  stop                    Stop environment (alias)
 infra
  infra:start             Start environment
  infra:stop              Stop environment
 qa
  qa:phpstan              Run PHPStan
  qa:cs-fix               Fix PHP code style

Les alias (aliases: ['start']) permettent d'exposer les commandes fréquentes sans le préfixe de namespace. castor start et castor infra:start exécutent la même tâche.

Gestion des binaires cross-platform

Un binaire compilé pour macOS ne fonctionne pas dans un container Linux. Castor permet de vérifier la présence d'un binaire dans le container et de le télécharger si nécessaire :

PHP
const BIOME_CONTAINER_PATH = '/tmp/biome';

function ensureBiomeBinary(): void
{
    $process = run(
        'docker compose exec php sh -c "test -x ' . BIOME_CONTAINER_PATH . ' && echo ok || echo missing"',
        context: context()->withQuiet()->withAllowFailure(),
    );

    if (trim($process->getOutput()) === 'ok') {
        return;
    }

    io()->note('Downloading Biome binary for Linux container...');
    run('docker compose exec php bin/console biomejs:download /tmp');
}

#[AsTask(name: 'biome-fix', description: 'Fix JS/TS files with Biome')]
function biome_fix(): void
{
    ensureBiomeBinary();
    run('docker compose exec php ' . BIOME_CONTAINER_PATH . ' check --write --unsafe assets/');
}

La fonction ensureBiomeBinary() est un helper réutilisable. Elle vérifie si le binaire existe et est exécutable dans le container via test -x. Si ce n'est pas le cas, elle lance le téléchargement. Cette vérification évite de télécharger le binaire à chaque exécution de la tâche.

Constantes partagées entre fichiers

Pour éviter la duplication de valeurs de configuration, Castor utilise les constantes PHP standard :

PHP
// .castor/deploy.php
namespace deploy;

const DEPLOY_HOST = 'lecode';
const DEPLOY_DIR = '~/app';
const IMAGE_NAME = 'lecodeestdanslepre';
PHP
// .castor/db.php
namespace db;

use const deploy\DEPLOY_HOST;
use const deploy\DEPLOY_DIR;

#[AsTask(name: 'sync', description: 'Sync database from production')]
function sync_from_prod(): void
{
    run('ssh ' . DEPLOY_HOST . ' "cd ' . DEPLOY_DIR . ' && ..."');
}

L'import use const de PHP rend les dépendances entre fichiers explicites.

Structure de fichiers recommandée

project/
├── castor.php              # Point d'entrée
└── .castor/
    ├── infra.php           # start, stop, logs, sh
    ├── db.php              # migrations, sync, backup
    ├── qa.php              # phpstan, cs-fixer, biome
    ├── test.php            # unit, functional, smoke
    ├── deploy.php          # build, push, deploy
    └── utils.php           # cache:clear, console

Le fichier castor.php à la racine charge les fichiers du dossier .castor/ :

PHP
<?php

declare(strict_types=1);

foreach (glob(__DIR__ . '/.castor/*.php') as $file) {
    require_once $file;
}

Le dossier .castor/ permet de séparer les tâches par domaine sans polluer la racine du projet. Castor détecte automatiquement le fichier castor.php à la racine du projet.

Limites

Overhead PHP. Chaque invocation de Castor démarre le runtime PHP. Pour des tâches exécutées en boucle serrée (traitement de fichiers par milliers), Bash ou un binaire compilé sera plus rapide. Pour de l'orchestration de commandes (démarrer Docker, lancer des migrations, exécuter des tests), l'overhead est négligeable par rapport au temps d'exécution des commandes elles-mêmes.

Dépendance au langage. L'ensemble de l'équipe doit maîtriser PHP pour maintenir les tâches. Dans un projet Symfony, c'est acquis. Dans une équipe mixte (développeurs PHP + ops qui travaillent en Bash/Python), l'adoption peut poser problème.

Écosystème. Make existe depuis 1976 et dispose d'une documentation exhaustive. Castor est un projet récent avec un périmètre fonctionnel en cours d'extension. La documentation officielle (https://castor.jolicode.com/) couvre les cas d'usage courants. Pour des besoins avancés non documentés, le code source sur GitHub reste la référence.

Portabilité. Les tâches Castor sont écrites en PHP, mais elles exécutent des commandes système (docker compose, ssh, pg_dump). La portabilité dépend de la disponibilité de ces commandes sur la machine cible, pas de Castor lui-même.

Conclusion

Castor résout un problème précis : permettre à une équipe PHP d'écrire ses tâches d'automatisation dans le même langage que son application. La gestion d'erreurs par exceptions, le typage, et l'accès à SymfonyStyle pour le feedback utilisateur sont des acquis du développement PHP qui ne sont pas disponibles en Bash ou Make sans effort supplémentaire.

L'outil est pertinent quand l'équipe travaille déjà en PHP et que les tâches à automatiser relèvent de l'orchestration : démarrer un environnement, lancer des tests, synchroniser des données, déployer. Pour du traitement de fichiers en volume ou de l'administration système pure, Bash reste mieux adapté.

Le compromis principal est la dépendance au langage. Un Makefile est lisible par n'importe quel développeur indépendamment de sa stack. Un fichier Castor suppose une maîtrise de PHP. Dans un projet Symfony, ce n'est pas un obstacle. Dans un contexte multi-stack, c'est un facteur à considérer.

Poursuivre la lecture

Sélectionné avec soin pour vous.

Sécurité

OWASP ZAP : comment implémenter un audit DAST automatisé avec Symfony et Docker

Passer du SAST au DAST en intégrant OWASP ZAP à votre workflow local via Docker et Castor. Un guide technique pour un audit de sécurité automatisé.

4 min de lecture
DX

Les délimiteurs Twig : ce problème d'espace blanc que vous ignorez

Gaps inline-block, diffs bruyants, layouts instables : comprenez l'impact des délimiteurs Twig sur l'espace blanc et adoptez les bonnes pratiques avec {%- et {{-.

8 min de lecture
DX

Comment tailwind_merge résout les conflits de classes dans Twig

Tailwind_merge dedoublonne les classes Tailwind en conflit dans Twig. Découvrez comment ce filtre rend vos composants plus prévisibles et maintenables.

4 min de lecture