Skip to content
intermediate Phase 8 · Composer and Autoloading

Running Composer Scripts

Automate tasks like linting and asset building from Composer.

35m
0 problems
Topic Progress 0%

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:

  • @php resolves to the exact PHP binary Composer itself is running, so a machine with php8.1 on PATH and PHP 8.3 in the project still executes your tool under the project's interpreter. Bare php trusts whatever the shell finds, which is how "works on my machine" gets a second life inside tooling.
  • Binary names like phpunit resolve to vendor/bin/phpunit automatically, because scripts run with vendor/bin prepended 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-cmd exiting 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 is composer 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 a pre-update-cmd that autoloads a class from vendor/ fails exactly when you are trying to install it. Keep pre-hooks to root-package code and plain shell; put anything that needs vendor/ 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?

Question 1 options

2. A cache must be refreshed whenever installed packages change OR the autoloader is manually re-dumped. Which hook should hold the refresh?

Question 2 options

3. You define a script named "validate" and run `composer validate`, but Composer’s own subcommand runs instead. How do you invoke your script?

Question 3 options

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?

Question 4 options

Flashcards

Question

How do you run a Composer script, pass arguments to it, and read its failure?

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?

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?

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?

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?

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.

Continue Learning

Before You Start

Related Reading