DEV Community

Cover image for Castor: A PHP-Native Task Runner That Replaces Your Makefile
Gabriel Anhaia
Gabriel Anhaia

Posted on

Castor: A PHP-Native Task Runner That Replaces Your Makefile


You open the Makefile in a PHP project you inherited. There are 40 targets. One of them shells out to a three-line Bash loop that greps git diff, pipes it through xargs, and calls php-cs-fixer on the result. It broke last Tuesday because someone's filename had a space in it. The fix is a quoting change you have to reason about across two languages at once: Make's $$ escaping and Bash's IFS.

That's the tax. Your whole team writes PHP all day, then drops into a second and third language the moment they need to automate anything. Make eats tabs. Bash eats spaces. Neither one knows what a composer.json is.

Castor removes that layer. It's a task runner from the JoliCode team where every task is a plain PHP function. Arguments, options, parallel execution, run-only-when-changed caching: all of it stays in the language you already know.

Install it, write one task

Castor is a single tool you install once, then a castor.php file at the repo root holds your tasks.

curl "https://castor.jolicode.com/install" | bash
Enter fullscreen mode Exit fullscreen mode

A task is a function with the #[AsTask] attribute:

<?php

use Castor\Attribute\AsTask;

use function Castor\io;
use function Castor\run;

#[AsTask(description: 'Run the test suite')]
function test(): void
{
    io()->title('Running PHPUnit');
    run('vendor/bin/phpunit');
}
Enter fullscreen mode Exit fullscreen mode

Run castor with no arguments and you get a Symfony Console listing of every task, its description, and its arguments. Run castor test and it executes. The run() helper wraps Symfony Process, so you get streamed output, exit-code handling, and timeouts for free.

Compare that to the Make equivalent, where test: is a target, the command runs in a fresh subshell, and a failing pipe stays green unless you remembered set -o pipefail.

Arguments and options are function parameters

This is where the Bash tangle usually starts. In a Makefile, passing a value means environment variables or positional $(1) gymnastics. In Castor, an argument is a function parameter, and its type drives the parsing.

<?php

use Castor\Attribute\AsTask;

use function Castor\io;
use function Castor\run;

#[AsTask(description: 'Import a fixtures file')]
function import(string $file, bool $force = false): void
{
    if (!$force && !is_file($file)) {
        io()->error("No file at {$file}");

        return;
    }

    run(['php', 'bin/import.php', $file]);
}
Enter fullscreen mode Exit fullscreen mode

Castor reads the signature. $file has no default, so it becomes a required argument. $force is a boolean with a default, so it becomes an --force flag. You call it like this:

castor import data/seed.sql --force
Enter fullscreen mode Exit fullscreen mode

No getopts. No manual $# counting. When you want to override the auto-derived name or add a description, the #[AsArgument] and #[AsOption] attributes do it:

use Castor\Attribute\AsArgument;
use Castor\Attribute\AsTask;

use function Castor\run;

#[AsTask()]
function deploy(
    #[AsArgument(description: 'Target host')]
    string $host,
): void {
    run(['ssh', $host, 'deploy.sh']);
}
Enter fullscreen mode Exit fullscreen mode

Because $file is a real PHP string, you get real PHP validation. Passing an array to run() instead of a string means no shell parses it, so a filename with a space in it is a non-event. The quoting bug from the intro cannot happen.

Dependencies are function calls

Make's dependency graph is its main selling point: build: deps means deps runs first. It's also where Makefiles rot, because the graph is implicit and the prerequisites are strings, not code.

Castor has no separate dependency syntax, and it does not need one. A task that depends on another task calls it:

<?php

use Castor\Attribute\AsTask;

use function Castor\io;
use function Castor\run;

#[AsTask(description: 'Install dependencies')]
function deps(): void
{
    run('composer install --no-interaction');
}

#[AsTask(description: 'Build assets, deps first')]
function build(): void
{
    deps();
    run('npm run build');
}
Enter fullscreen mode Exit fullscreen mode

build() calls deps(). That is the whole dependency mechanism. It is ordinary function composition, so conditionals, loops, and early returns work the way they do everywhere else in your code. You can guard a step behind an if, wrap it in a try/catch, or skip it based on an environment check. No .PHONY, no tab-versus-space, no separate mental model for "the build graph."

Parallel runs without a subshell circus

Running two commands at once in Bash means backgrounding with &, collecting PIDs, and calling wait. Getting their outputs back into variables is worse. Castor ships a parallel() helper:

<?php

use Castor\Attribute\AsTask;

use function Castor\context;
use function Castor\io;
use function Castor\parallel;
use function Castor\run;

#[AsTask(description: 'Run static analysis and tests together')]
function ci(): void
{
    [$stan, $tests] = parallel(
        fn () => run(
            'vendor/bin/phpstan analyse',
            context: context()->withQuiet(),
        ),
        fn () => run(
            'vendor/bin/phpunit',
            context: context()->withQuiet(),
        ),
    );

    io()->writeln($stan->getOutput());
    io()->writeln($tests->getOutput());
}
Enter fullscreen mode Exit fullscreen mode

Each closure runs concurrently. parallel() returns their results in order, so you can inspect exit codes and captured output afterward. On a CI job where PHPStan and PHPUnit each take a couple of minutes, running them together instead of back-to-back is a direct wall-clock saving, and you did not write a single wait call.

Context replaces the environment-variable soup

Working directory and environment values are where Make scripts get their cd ... && chains. Castor carries those in a Context object you thread through run():

use Castor\Attribute\AsTask;

use function Castor\context;
use function Castor\run;

#[AsTask()]
function migrate(): void
{
    run(
        'php bin/console doctrine:migrations:migrate',
        context: context()
            ->withWorkingDirectory('app')
            ->withEnvironment(['APP_ENV' => 'prod']),
    );
}
Enter fullscreen mode Exit fullscreen mode

No cd app && APP_ENV=prod ... string that breaks the moment a path has a space. The working directory and env are typed method calls, and they only apply to that process.

Run a task only when files changed

Make's timestamp model (a target is stale if a prerequisite is newer) is the one thing people miss when they leave it. Castor covers it with fingerprint(), which hashes file content and skips the callback when nothing changed:

use Castor\Attribute\AsTask;
use Castor\Fingerprint\FileHashStrategy;

use function Castor\fingerprint;
use function Castor\hasher;
use function Castor\run;

#[AsTask(description: 'Rebuild assets only when sources change')]
function assets(): void
{
    fingerprint(
        callback: fn () => run('npm run build'),
        id: 'assets-build',
        fingerprint: hasher()
            ->writeGlob('assets/**/*.ts', FileHashStrategy::Content)
            ->finish(),
    );
}
Enter fullscreen mode Exit fullscreen mode

Run castor assets twice and the second run does nothing, because the hash of your assets/**/*.ts files did not move. This is Make's incremental-build idea, expressed as content hashing instead of mtimes, which survives a git checkout that rewrites timestamps.

Why staying in PHP wins

The argument for Castor is not that Make and Bash cannot do these things. They can. It is that doing them means writing three languages to automate one, and the failures live in the seams between them: Make's tab rule, Bash's word splitting, the quoting handoff at each boundary.

Castor collapses that to one language. Your task code sees the same autoloader, the same composer.json, the same Symfony Process and Finder components your application already depends on. A junior can read a task without learning Make syntax. You can var_dump inside a task. You can extract a helper and unit-test it. The automation stops being a separate, fragile artifact and becomes code that lives by the same rules as the rest of the project.

Start small. Port the three or four make targets your team runs daily into a castor.php, keep the Makefile around for the rest, and delete targets as you go. The first time a filename with a space in it fails to break anything, you'll know why the trade was worth it.

The instinct here is the same one behind a well-structured application: keep each concern in one place, speak one language across a boundary, and don't let an external tool dictate the shape of your code. Automation is an edge of your system, and pulling it back into PHP is the same move as pulling infrastructure behind a port so the domain never has to care. That's the throughline of Decoupled PHP: build the thing so the tooling around it stays replaceable.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)