Defining and Running Scripts: run-script and the Bare-Name Shortcut
Every project accumulates commands the whole team must run the same way: the test suite, the linter, the asset build, the local dev server. Composer gives those commands a home in composer.json so nobody has to remember the flags:
{
"scripts": {
"test": "phpunit --colors=always",
"lint": ["@php tools/lint.php src", "@php tools/lint.php tests"],
"fix-cs": "php-cs-fixer fix",
"serve": "php -S localhost:8000 -t public"
}
}
Two shapes are accepted. A string runs one command; an array runs several in order, stopping at the first non-zero exit — which is what you want in CI, because a lint failure should abort before tests run. Two conventions inside those strings matter:
@phpresolves to the exact PHP binary Composer itself is running, so a machine withphp8.1on PATH and PHP 8.3 in the project still executes your tool under the project's interpreter. Barephptrusts whatever the shell finds, which is how "works on my machine" gets a second life inside tooling.- Binary names like
phpunitresolve tovendor/bin/phpunitautomatically, because scripts run withvendor/binprepended to PATH. You never type the vendor path.
Running them:
composer test # bare-name shortcut: any script without a colon works
composer run-script test # the explicit form; the only way when names collide
composer run-script -l # list every registered script
The shortcut is sugar over run-script, and it has one real edge case: composer dump-autoload, composer validate, and friends are Composer's own subcommands. A script named validate will never win the bare call — the native subcommand takes precedence — so scripts shadowed like this must go through composer run-script validate. Teams that hit this once adopt scripts:check-style prefixes and stop thinking about it.
Scripts do not have to be shell strings. A value naming a PHP static method runs in-process instead of spawning a shell:
{
"scripts": {
"fix-cs": "App\\Tools\\CodeStyle::fix"
}
}
That method receives the Composer\Script\Event which fired: getArguments() returns whatever followed --, getName() the event name, getComposer() the running instance. For it to be found at all, the class must resolve through your autoload rules — the PSR-4 mapping you set up in PSR-4 Autoloading is exactly what makes it loadable, and a callback missing from the classmap dies with a class-not-found error before your command runs.
Two manifest fields make the set discoverable:
{
"scripts-descriptions": { "test": "Run the full PHPUnit suite" },
"scripts-aliases": { "phpstan": ["stan", "analyze"] }
}
composer list and composer run -l print your descriptions instead of Composer's default "Runs the ... script as defined in composer.json", and aliases (Composer 2.7+) give everyone a shorter name without renaming the canonical one. composer validate warns when a description or alias points at a script that does not exist.
Script bodies can reference each other with @: "ci": ["@lint", "@test"] runs sibling scripts in order instead of duplicating their commands, and @putenv exports variables for the commands that follow it in the same list — "test": ["@putenv APP_ENV=testing", "@php vendor/bin/phpunit"]. Every behavior is then defined exactly once, and changing the test invocation changes every caller. That single-definition property is the real argument for scripts over a README full of shell snippets: documentation drifts, while an entry in scripts is executed by humans and CI through the same string.
Arguments pass through an explicit -- boundary:
composer test -- --filter=OrderTest --testdox
# runs: phpunit --colors=always --filter=OrderTest --testdox
Everything after -- is appended verbatim to the script's command. Exit codes propagate the same direction: composer test exits non-zero when PHPUnit does, so the same script works in a pre-push hook, in CI, and on a teammate's terminal with no re-implementation. That propagation is also why scripts are the automation primitive rather than a wiki page of shell snippets — and why a hook that exits non-zero can abort an install, as the next chapter shows.
One bound applies to every script you write: Composer kills a process that runs past 300 seconds by default. A dev server or a long suite dies mid-run unless you raise config.process-timeout, export COMPOSER_PROCESS_TIMEOUT=0 for one terminal, or list Composer\Config::disableProcessTimeout as the first entry of a single script's array.
Lifecycle Hooks: The Events That Run Without You Asking
Beyond named scripts, Composer defines events that fire during its own operations. Declare a script with an event's name and it runs automatically at that moment — this is how applications keep working directories healthy without a human remembering a step.
The events you will actually use:
| Event | Fires when |
|---|---|
pre-install-cmd |
before composer install resolves against a lock file |
post-install-cmd |
after composer install finishes resolving (fresh installs and lock replays) |
pre-update-cmd |
before composer update — and before an install that finds no lock file |
post-update-cmd |
after composer update — and after an install that had no lock file |
post-autoload-dump |
any operation regenerates the autoloader: install, update, and dump-autoload itself |
Two of those rows carry surprises interviewers like. post-update-cmd is not "updates only": an install with no composer.lock has to resolve versions like an update, so a brand-new clone fires post-update-cmd rather than post-install-cmd. And post-autoload-dump fires when you run composer dump-autoload by hand, which is what makes it the right home for anything derived from the installed package set.
The canonical example is Laravel's cache-warmup dance, which any templated app repeats:
{
"scripts": {
"post-autoload-dump": [
"@php artisan package:discover",
"@php artisan horizon:terminate"
]
}
}
Every composer install, composer update, and composer dump-autoload now refreshes the discovered-packages cache — nobody can forget the step, because there is no step to forget. Choosing the event is a correctness question: caches derived from installed packages belong on post-autoload-dump (it fires even when you re-dump without installing), while behavior that must differ between "new tree from the lock" and "freshly resolved tree" splits across post-install-cmd and post-update-cmd.
Hooks are not a black box. You can run any of them on demand, which is how you debug a broken one without reinstalling anything:
composer run-script post-install-cmd -- --check
--check reaches the handler as an argument — a shell script sees it in $argv, a PHP callback through $event->getArguments() — so the same hook doubles as a manual repair tool.
Three boundaries keep hooks sane:
- Only the root package's scripts run. Composer does not execute lifecycle scripts declared by dependencies — a package you install cannot run arbitrary hooks in your project. (Plugins are a separate, explicit trust decision gated by
allow-plugins, covered in Initializing a Project with Composer.) - A failing hook fails the operation.
post-install-cmdexiting non-zero aborts the install, which is a feature in CI and a puzzle on a laptop when a stale cache makes the hook crash. The escape hatch iscomposer install --no-scripts— dependencies install, hooks do not run, and you fix the script separately. pre-*hooks must be self-contained. Composer's own guidance is that no Composer-managed dependency is guaranteed to exist before install or update runs, so apre-update-cmdthat autoloads a class fromvendor/fails exactly when you are trying to install it. Keep pre-hooks to root-package code and plain shell; put anything that needsvendor/after the work instead.
Hooks inherit the same execution rules as named scripts — @php resolves to Composer's interpreter and vendor/bin is on PATH — and --no-scripts suppresses every hook for that run across install, update, and dump-autoload, not just the one that bit you.
Migration scripts deserve a warning label. Running phinx migrate or artisan migrate on post-install-cmd sounds diligent until the first deploy where a migration is incompatible with the code still running — production takes the downtime at the worst moment. Migrations are a deliberate, observable deploy step for a reason; let hooks handle caches and discovery, not schema.
Conditional Hooks: The Event Name Argument and Composer's Environment
One script, two events — the standard way to deduplicate hooks without losing the distinction between them. Composer appends the triggering event's name to the command line, so a hook can branch on $argv:
{
"scripts": {
"post-install-cmd": ["@php tools/cache-rebuild.php"],
"post-update-cmd": ["@php tools/cache-rebuild.php"]
}
}
<?php
// tools/cache-rebuild.php
$event = $argv[1] ?? '';
if ($event === 'post-update-cmd') {
echo "New dependency tree resolved — invalidating shared cache\n";
invalidateRemoteCache();
}
rebuildLocalCache();
Run composer update and the script announces the tree change; a plain install stays quiet but still rebuilds. The event name arriving as an argument is Composer's contract — post-install-cmd when install ran, post-update-cmd when update did — and it is the difference between "clear a cache" and "react to resolution." A PHP callback gets the same information through $event->getName().
Composer also exports environment variables that hooks read directly, the most useful being COMPOSER_DEV_MODE:
<?php
$dev = getenv('COMPOSER_DEV_MODE') !== '0'; // false when the run used --no-dev
That single variable separates deploy behavior from developer behavior without parsing command lines: a post-install-cmd that warms a dev toolbar checks it and stays silent on production installs. It is set for install, update, and dump-autoload, carrying the value of the last real install or update. COMPOSER_BINARY (the path to the composer executable) lets a script shell back into Composer without guessing whether the machine calls it composer, composer.phar, or php composer.phar.
Where appended arguments land is controllable too. Composer 2.8 appends whatever follows -- to the end of each command in the array, and two markers override that: @no_additional_args keeps a command argument-free, @additional_args places them exactly where you want.
{
"scripts": {
"run-commands": [
"echo checking @no_additional_args",
"phpunit @additional_args --colors=always"
]
}
}
composer run-commands -- --filter=OrderTest now runs a bare echo and a phpunit --filter=OrderTest --colors=always. When a command needs real flags of its own — declared options, --help output, typed arguments — register a Symfony Console command class instead: a class ending in Command can be assigned directly to a script name (Composer 2.5+), and composer my-cmd --arbitrary-flag works with no -- boundary at all. The trade-off is that it runs against Composer's bundled console, not the version your project requires.
Portability is the last piece, and it is where hand-rolled shell habits break. "test": "phpunit && php-cs-fixer fix" works on Linux and macOS and dies on Windows, where scripts execute through cmd.exe and &&-chaining plus POSIX quoting are unreliable. The portable form is the array — each entry is its own process, Composer handles sequencing and the stop-on-failure rule:
"ci": ["@php tools/lint.php", "@composer test", "@php tools/coverage-check.php"]
Inside that list, @php pins the interpreter and @composer re-enters Composer itself (used here to run the test script from inside ci — the @ prefix is what distinguishes "run the composer script named test" from "run a binary named composer"). You cannot chain two Composer or PHP commands inside one string with &&; the array is the supported spelling of "and then."
Scripts written this way — arrays, @php, event-name branching — run identically on a developer's laptop, the CI container, and the production deploy box, which is the whole point of putting the automation in the manifest instead of a README. "Where do you put project automation in a PHP repo?" is a fair interview question, and the answer is Composer scripts: name @php, the -- boundary, and post-autoload-dump, and you have shown you have built one that survived a team. The other half of the manifest decides what those hooks install — that is Version Constraints and Stability.
Quiz
1. Your composer.json defines "test": "phpunit". What does `composer test -- --filter=OrderTest` execute?
2. A cache must be refreshed whenever installed packages change OR the autoloader is manually re-dumped. Which hook should hold the refresh?
3. You define a script named "validate" and run `composer validate`, but Composer’s own subcommand runs instead. How do you invoke your script?
4. A post-install-cmd hook crashes on a developer's machine after a corrupt cache. What does Composer do, and what is the sanctioned escape?
Flashcards
Question
How do you run a Composer script, pass arguments to it, and read its failure?
Click to reveal answer
Answer
`composer <name>` (bare shortcut) or `composer run-script <name>` (always works, required when the name collides with a native subcommand). Arguments after `--` append verbatim to the command. The script’s exit code becomes Composer’s — non-zero fails the operation, which is what CI relies on.
Question
When do post-install-cmd, post-update-cmd, and post-autoload-dump each fire?
Click to reveal answer
Answer
post-install-cmd: after composer install resolves against a lock file. post-update-cmd: after composer update, and also after an install that finds no lock file (a fresh clone resolves like an update). post-autoload-dump: after every autoloader regeneration — install, update, and bare dump-autoload — which makes it the right home for caches derived from installed packages.
Question
Do installed packages' own lifecycle scripts run in your project?
Click to reveal answer
Answer
No — only the root package’s scripts execute for lifecycle events, so a dependency cannot run arbitrary hooks on install. Plugins are the separate, explicit trust mechanism gated by the allow-plugins config. A failing root hook, however, aborts the whole operation; --no-scripts is the recovery path.
Question
How does a hook know which event triggered it, and what env does Composer provide?
Click to reveal answer
Answer
Composer appends the event name to the command line — a script reads it as $argv[1] ('post-install-cmd' vs 'post-update-cmd') and can branch. Environment variables add context: COMPOSER_DEV_MODE is '0' for --no-dev installs (deploy detection), COMPOSER_BINARY gives the composer executable's path for nested calls.
Question
Why do portable Composer scripts use arrays and @php instead of shell one-liners?
Click to reveal answer
Answer
An array runs each command as its own process with stop-on-failure sequencing, avoiding &&-chains and quoting that break under Windows cmd.exe. @php resolves to Composer’s own PHP binary (a consistent interpreter everywhere), and @composer re-enters Composer to call another script (for example "@composer test" inside a ci script).
Revision Notes
Key Takeaways
- 1. Scripts in composer.json are the team's executable documentation: bare-name shortcut, run-script for shadowed names, -- to pass arguments, exit codes to fail CI
- 2. @php and vendor/bin resolution make scripts interpreter- and path-portable; command arrays avoid the Windows shell traps of &&-chains
- 3. post-autoload-dump is the correct home for anything derived from installed packages because it fires on install, update, and manual re-dumps alike
- 4. Only root-package lifecycle scripts execute — dependencies cannot hook your install — and a failing hook aborts the operation unless you run --no-scripts
- 5. Hooks receive the event name as an argument and COMPOSER_DEV_MODE in the environment, which is how one script serves both developer installs and --no-dev deploys
Interview Tips
- • "Where would you put a cache warmup in a PHP project?" — post-autoload-dump, and say why it beats post-install-cmd (fires on dump-autoload too); mention the artisan package:discover precedent
- • "How do you run tests via Composer with arguments?" — `composer test -- --filter=...`; the -- boundary and exit-code propagation are the two facts that prove you have wired CI
- • "A Composer install fails on one machine" — walk the hook: failing post-install-cmd aborts by design; reproduce with --no-scripts, then fix the script; note COMPOSER_DEV_MODE differences between the machines
- • "Are dependency scripts dangerous?" — no: only root-package scripts run; the trust decision in Composer is allow-plugins, which is reviewed deliberately
Cheat Sheet
Running Composer Scripts
Define and call:
"scripts": {
"test": "phpunit --colors=always",
"lint": ["@php tools/lint.php src", "@php tools/lint.php tests"],
"post-autoload-dump": ["@php artisan package:discover"]
}
composer test # bare-name shortcut
composer run-script test # explicit (needed when a native subcommand shadows the name)
composer test -- --filter=X # args after -- append to the command
composer run-script -l # list scripts (scripts-descriptions show here)
Rules: arrays stop at first non-zero exit · exit codes propagate to CI · @php = Composer's PHP binary · bare binaries resolve vendor/bin · only root-package scripts run · a failing hook fails the operation; --no-scripts skips all hooks · default process timeout is 300s (config.process-timeout).
Events: pre-install-cmd / post-install-cmd (install against a lock) · pre-update-cmd / post-update-cmd (update, or install with no lock) · post-autoload-dump (install + update + dump-autoload — caches go here) · hooks receive the event name as $argv[1] / $event->getName(); COMPOSER_DEV_MODE=0 marks --no-dev runs.
Portability: command arrays instead of && chains · @composer re-enters Composer · @putenv VAR=value · @no_additional_args / @additional_args (2.8) · PHP callbacks and Symfony Command classes run in-process.