- 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
A new hire clones the repo, runs composer install, and starts writing code. Three days in, their PR fails CI on a formatting rule they never knew existed. They fix it, push again, and now it fails PHPStan. Push again, fails on a composer.json that isn't normalized. Four round-trips to green on a two-line change.
None of that is their fault. The repo never told them the rules. It waited until CI to enforce them, which is the most expensive possible place to find out.
Composer already runs on every install. It can run your checks too. The rest lives in composer.json: lifecycle scripts, script aliases, composer-normalize, and a small custom plugin that fires a hook nobody has to remember to run.
The scripts section is a task runner you already have
Every PHP repo ships with a task runner. It's the scripts key in composer.json, and most teams use it for one line and then reach for Make or a shell script for the rest.
You don't need to. A script entry is a name mapped to one or more commands:
{
"scripts": {
"test": "phpunit",
"stan": "phpstan analyse",
"cs": "php-cs-fixer fix --dry-run --diff"
}
}
Now composer test runs PHPUnit. composer stan runs PHPStan. Composer puts vendor/bin on the PATH for the duration of the script, so you write phpunit, not vendor/bin/phpunit. That alone kills a class of "works on my machine" path bugs.
An entry can be an array. Composer runs each line in order and stops at the first non-zero exit:
{
"scripts": {
"check": [
"@cs",
"@stan",
"@test"
]
}
}
The @ prefix references another script. composer check now runs formatting, then static analysis, then tests, bailing the moment one fails. That's the same sequence CI runs, available on the laptop before the push.
Pass arguments through with --
The thing that sends people back to Make is arguments. You want composer test for the full suite but composer test -- --filter=OrderTest for one class. Composer forwards anything after -- to the underlying command:
composer test -- --filter=OrderTest
composer stan -- --level=max src/Payment
For anything more involved, Composer exposes a @php proxy that runs a script with the same PHP binary Composer itself is using, which matters when the machine has more than one PHP version installed:
{
"scripts": {
"serve": "@php -S localhost:8080 -t public"
}
}
Lifecycle scripts: the repo that checks itself on install
Script names that match Composer's own events fire automatically. post-install-cmd runs after composer install. post-update-cmd runs after composer update. There are events for autoload dumps, package installs, and more. The full list is in the Composer scripts docs.
This is where the repo starts teaching itself. Wire a validation step to post-install-cmd and every clone gets checked the moment dependencies land:
{
"scripts": {
"post-install-cmd": [
"@validate-env"
],
"validate-env": "php bin/check-requirements.php"
}
}
Keep the lifecycle work honest. post-install-cmd runs on every developer machine and in every CI job, so it has to be fast and side-effect-free. A requirements check, an autoload validation, a "you forgot to copy .env" warning. Not a full test suite, and never a network call that can hang an install.
A small requirements check reads well as its own file:
<?php
declare(strict_types=1);
$errors = [];
if (PHP_VERSION_ID < 80400) {
$errors[] = 'PHP 8.4+ required, found ' . PHP_VERSION;
}
foreach (['pdo', 'mbstring', 'intl'] as $ext) {
if (!extension_loaded($ext)) {
$errors[] = "Missing extension: {$ext}";
}
}
if ($errors !== []) {
fwrite(STDERR, implode(PHP_EOL, $errors) . PHP_EOL);
exit(1);
}
Exit non-zero and Composer reports the failure. The new hire learns the PHP version requirement on install, not on their third failed PR.
Keep composer.json itself clean
Two files rot faster than anything else in a repo: the lock file and composer.json. Merge conflicts, keys in random order, dependencies in require that belong in require-dev. ergebnis/composer-normalize fixes the ordering problem for good.
Install it as a plugin:
composer require --dev ergebnis/composer-normalize
It registers itself as a Composer command. Run it and your composer.json gets a canonical key order, sorted package lists, and consistent formatting:
composer normalize
The value shows up in review. When every composer.json change produces a minimal, ordered diff, a reviewer can see that a dependency moved from require to require-dev at a glance instead of untangling a reshuffled file. Wire the dry-run into your check script so CI enforces it:
{
"scripts": {
"normalize": "@composer normalize --dry-run"
}
}
The @composer proxy calls the same Composer binary that's running the script, so the plugin resolves correctly regardless of how Composer was installed.
Custom plugins: a hook nobody has to remember
Scripts cover most needs. When you want behavior that travels with a package (so every repo that requires it gets the hook automatically, no scripts copy-paste), you write a plugin.
A Composer plugin is a class that implements PluginInterface and, usually, EventSubscriberInterface. The plugin's package sets "type": "composer-plugin" and points at the class:
{
"name": "acme/repo-guard",
"type": "composer-plugin",
"require": {
"composer-plugin-api": "^2.3"
},
"extra": {
"class": "Acme\\RepoGuard\\Plugin"
},
"autoload": {
"psr-4": {
"Acme\\RepoGuard\\": "src/"
}
}
}
The class subscribes to Composer events and runs code when they fire:
<?php
declare(strict_types=1);
namespace Acme\RepoGuard;
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
final class Plugin implements
PluginInterface,
EventSubscriberInterface
{
public function activate(
Composer $composer,
IOInterface $io,
): void {}
public function deactivate(
Composer $composer,
IOInterface $io,
): void {}
public function uninstall(
Composer $composer,
IOInterface $io,
): void {}
public static function getSubscribedEvents(): array
{
return [
ScriptEvents::POST_INSTALL_CMD => 'onPostInstall',
];
}
public function onPostInstall(Event $event): void
{
$io = $event->getIO();
if (!file_exists(getcwd() . '/.env')) {
$io->writeError(
'<warning>[repo-guard] No .env found. '
. 'Copy .env.example before running.</warning>'
);
}
}
}
Require that package in any repo and the .env check runs on every install without a single line in that repo's scripts. The hook travels with the dependency. Update the plugin once, every consumer picks it up on their next composer update.
Because a plugin can execute code during install, Composer treats it as a trust boundary. Composer 2.2 introduced allow-plugins, and it is required. A plugin that isn't allowed won't run. List yours explicitly:
{
"config": {
"allow-plugins": {
"ergebnis/composer-normalize": true,
"acme/repo-guard": true
}
}
}
Never set allow-plugins to true wholesale. That hands install-time code execution to every transitive dependency. Name each plugin you trust and leave the rest denied.
Wiring it together
Here's the shape of a composer.json that pulls its own weight:
{
"scripts": {
"post-install-cmd": ["@validate-env"],
"validate-env": "php bin/check-requirements.php",
"cs": "php-cs-fixer fix --dry-run --diff",
"stan": "phpstan analyse",
"normalize": "@composer normalize --dry-run",
"test": "phpunit",
"check": ["@normalize", "@cs", "@stan", "@test"]
},
"config": {
"allow-plugins": {
"ergebnis/composer-normalize": true
}
}
}
One command, composer check, runs the exact gate CI runs. A fresh clone gets validated on install. composer.json stays ordered. And the checks that used to live in a README nobody reads now live in the file everybody runs.
The reviewer stops being the linter. The new hire stops learning the rules by failing CI. The repo says what it expects, out loud, at the first moment it can.
Where the line is
Composer scripts are glue, not application logic. The moment a script grows conditionals, environment branching, and its own error handling, it wants to be a real PHP file that the script calls (php bin/whatever.php), tested like any other code. A one-liner in scripts is fine. A twelve-line shell incantation with && chains is a bug waiting to happen on the one machine with a different shell.
Keep the automation at the edge of the repo, where install and CI live. Keep the logic it triggers in typed, testable PHP. That boundary is the same one that keeps a framework's conventions from leaking into your domain: the plumbing that wires the app together is not the app. Decoupled PHP is about drawing that line everywhere it matters, so the parts that outlive the framework stay clean while the glue around them stays replaceable.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)