- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
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');
}
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]);
}
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
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']);
}
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');
}
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());
}
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']),
);
}
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(),
);
}
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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)