Composer Scripts and Lifecycle Hooks for Custom Automation
AI generated
<?php
8.4
PHP · Composer · Automation · DevOps
Composer Scripts and Lifecycle Hooks
for custom automation in your project

Composer can do far more than just install dependencies: through Composer scripts and lifecycle hooks, every step of the installation process, from the very first composer install to the last autoload dump, can be extended with custom automation. Whether a shell command or a PHP callback via Composer\Script\Event, anyone who knows the complete event list replaces manual setup steps, forgotten cache clears, and improvised onboarding instructions with reproducible, versioned automation right inside composer.json.

16 min read pre-install-cmd · post-update-cmd · post-autoload-dump Composer 2.x · PHP 8.4

1. Why Composer scripts: automation beyond install and update

In most projects, Composer is perceived only as a tool for resolving and installing dependencies. Yet Composer ships with a complete lifecycle system that fires its own events on every composer install, composer update, and practically every other operation. Composer scripts hook into exactly these events and run shell commands or PHP code there, with no additional tooling required. Anyone who has never used Composer scripts is giving up an automation mechanism that already exists in every PHP installation.

The motivation for custom lifecycle hooks is usually the same: manual steps after checking out a project get forgotten, README instructions go stale faster than the code, and every new developer's onboarding repeats the same copy-paste commands. With Composer hooks, this logic moves directly into composer.json, versioned together with the project, instead of gathering dust in a separate wiki article or bash script. Typical use cases include copying a .env.example to .env, creating directories for logs and cache, setting file permissions, or automatically clearing a cache directory after every update.

The decisive advantage over an external Makefile or shell script: Composer scripts run automatically at the right point in the installation process, without a developer having to remember an extra command. And because the mechanism is part of Composer itself, it works regardless of the framework in use, whether plain PHP 8.4 with no framework, Symfony, or an internal company package with no framework binding at all.

2. The complete event list at a glance

Composer distinguishes between several categories of events that Composer scripts can hook into. The command events bracket the central operations: pre-install-cmd and post-install-cmd fire before and after composer install, pre-update-cmd and post-update-cmd analogously before and after composer update. These four events are the standard place for project-wide automation, because they trigger on every complete installation run, regardless of how many individual packages are affected.

A second category fires per package rather than per command: pre-package-install, post-package-install, pre-package-update, post-package-update, pre-package-uninstall, and post-package-uninstall. These lifecycle hooks are mainly relevant for Composer plugins that need to react to individual dependencies; they rarely appear in normal project scripts. In addition there is post-autoload-dump, which fires after every regeneration of the autoloader, whether triggered by install, update, or an explicit composer dump-autoload, as well as post-create-project-cmd, which runs exclusively when creating a new project with composer create-project and is ideal for initial configuration.

Further specialized events round out the list: pre-archive-cmd and post-archive-cmd bracket the composer archive command, pre-autoload-dump fires before autoloader generation and allows manipulating the autoload configuration at runtime, and post-root-package-install runs once, directly after the root package is installed during create-project. Anyone who wants to use Composer scripts effectively should not memorize these events, but instead pick the right category based on the automation task at hand: project-wide or per-package, one-off or recurring.


{
    "name": "acme/example-project",
    "type": "project",
    "require": {
        "php": ">=8.4"
    },
    "scripts": {
        "pre-install-cmd": "Acme\\Setup\\ComposerHooks::preInstall",
        "post-install-cmd": [
            "Acme\\Setup\\ComposerHooks::postInstall",
            "@php bin/clear-cache.php"
        ],
        "pre-update-cmd": "Acme\\Setup\\ComposerHooks::preInstall",
        "post-update-cmd": [
            "Acme\\Setup\\ComposerHooks::postInstall",
            "@php bin/clear-cache.php"
        ],
        "post-autoload-dump": "Acme\\Setup\\ComposerHooks::postAutoloadDump",
        "post-create-project-cmd": "Acme\\Setup\\ComposerHooks::firstTimeSetup"
    }
}

3. Two kinds of script handlers: shell commands and PHP callbacks

For every event, the scripts block in composer.json accepts two fundamentally different kinds of handlers. The first variant is a shell command as a string, executed by Composer in a new process, for example "post-install-cmd": "chmod -R 775 var/cache". That is quick to write, but has a drawback: shell commands are platform-dependent, a command that works on Linux and macOS often fails on Windows without WSL. Composer scripts that need genuine portability should therefore only use this variant to invoke their own platform-independent PHP scripts, for example with the @php prefix.

The second, and for real Composer scripts far more powerful, variant is the PHP callback as a static class method, written as "Vendor\\Class::method". Composer does not instantiate a class for this, it calls the method statically and passes a Composer\Script\Event object as the only parameter. The decisive advantage: the callback runs in the same PHP process as Composer itself, without the overhead of a new child process, and has full access to Composer's runtime environment, including the IO interface and configuration.

One detail that is often overlooked: for a PHP callback to work as a Composer hook, the referenced class must already be discoverable via the autoloader at the time the event fires. On a very first composer install in a fresh checkout with no vendor directory, this can create a chicken-and-egg problem if the handler class itself only becomes autoloadable through the installation. In practice, handler classes are therefore usually placed inside a dedicated, already existing namespace within the root package, whose PSR-4 mapping is active regardless of installation state.


<?php

declare(strict_types=1);

namespace Acme\Setup;

use Composer\Script\Event;

/**
 * Static Composer script handlers for pre- and post-install hooks.
 */
final class ComposerHooks
{
    /**
     * Runs before install and update: writes a marker file so post hooks
     * can detect whether this was a fresh install or an update.
     */
    public static function preInstall(Event $event): void
    {
        $event->getIO()->write('<info>Preparing installation...</info>');
        file_put_contents(__DIR__ . '/../../.composer-pre-install', (string) time());
    }

    /**
     * Runs after install and update: the main entry point for project setup.
     */
    public static function postInstall(Event $event): void
    {
        $io = $event->getIO();
        $io->write('<info>Running post-install setup...</info>');

        // Delegate to a dedicated task runner instead of inlining logic here
        $runner = new ProjectSetup($event->getIO(), $event->isDevMode());
        $runner->ensureEnvFile();
        $runner->ensureDirectories();
    }
}

4. Composer\Script\Event in detail

Every PHP callback receives, as its only parameter, an instance of Composer\Script\Event, and this object is the single access point to the context in which the script runs. The method getIO() returns an IOInterface, through which formatted output can be written (write(), writeError()), interactive questions can be posed to the user (ask(), askConfirmation()), and the current verbosity level can be queried. For Composer scripts that need to inform the user or ask for confirmation, getIO() is the only correct way, rather than working directly with echo, because only this respects the behavior of Composer flags like --quiet.

The method getComposer() returns the full Composer instance with access to the currently installed root package, the configuration from composer.json and the global config.json, and the repository and installation managers. This makes it possible, for example, to programmatically determine which packages are installed or what version a given package has, without scanning the filesystem yourself. isDevMode() returns whether the call happened without the --no-dev flag, which matters for lifecycle hooks that need to behave differently between development and production, for example to enable debug tools only locally.

In addition, getName() returns the name of the currently firing event, useful in a single handler registered for multiple events that should react differently depending on which one fired. And getArguments() returns extra arguments passed after the double dash when invoking via composer run-script name -- arg1 arg2, so a Composer script can also be invoked with parameters.


<?php

declare(strict_types=1);

namespace Acme\Setup;

use Composer\Script\Event;

/**
 * Demonstrates the information available on the Composer\Script\Event object.
 */
final class EventInspector
{
    /**
     * Logs event context and reacts differently for dev vs. production installs.
     */
    public static function inspect(Event $event): void
    {
        $io = $event->getIO();
        $composer = $event->getComposer();
        $rootPackage = $composer->getPackage();

        $io->write(sprintf('<comment>Event:</comment> %s', $event->getName()));
        $io->write(sprintf('<comment>Package:</comment> %s (%s)', $rootPackage->getName(), $rootPackage->getPrettyVersion()));
        $io->write(sprintf('<comment>Dev mode:</comment> %s', $event->isDevMode() ? 'yes' : 'no'));

        $arguments = $event->getArguments();
        if ($arguments !== []) {
            $io->write(sprintf('<comment>Arguments:</comment> %s', implode(' ', $arguments)));
        }

        if (!$event->isDevMode() && $io->isInteractive()) {
            $io->askConfirmation('Running in production mode, continue? [Y/n] ', true);
        }
    }
}

5. Defining custom Composer commands

Besides the predefined lifecycle events, the scripts block can also be used as a plain alias mechanism for arbitrary custom commands. An entry like "test": "phpunit --colors=always" does not define a lifecycle event, it defines a freely named Composer script, invoked via composer run-script test or, in the short form, composer test. This short form always works as long as the chosen name does not collide with a built-in Composer command, otherwise run-script must be used explicitly.

Multiple commands can be written as an array, and Composer then runs them sequentially, aborting as soon as one of the commands returns an error. With the @ prefix, one script can reference another, already defined script from within a script, for example "post-install-cmd": ["@setup-env", "@clear-cache"], which composes complex chains from several named Composer scripts without duplicating logic. The scripts-descriptions entry adds a short description to custom commands, which then appears next to the command in the output of composer list, so custom Composer hooks are documented and discoverable for the whole team.


{
    "scripts": {
        "test": "phpunit --colors=always",
        "cs-check": "phpcs --standard=PSR12 src tests",
        "cs-fix": "phpcbf --standard=PSR12 src tests",
        "analyse": "phpstan analyse src --level=8",
        "setup-env": "Acme\\Setup\\ComposerHooks::ensureEnvFile",
        "clear-cache": "Acme\\Setup\\ComposerHooks::clearCache",
        "check": [
            "@cs-check",
            "@analyse",
            "@test"
        ],
        "post-install-cmd": [
            "@setup-env",
            "@clear-cache"
        ]
    },
    "scripts-descriptions": {
        "test": "Run the full PHPUnit test suite",
        "check": "Run coding standard, static analysis and tests in one go",
        "cs-fix": "Automatically fix coding standard violations"
    }
}

6. Step-by-step practical example: setup after every install

A realistic example brings the previous sections together: after every composer install and composer update, the project should automatically check whether an .env file exists, and if not, copy it from .env.example. In addition, the directories var/log and var/cache should be created if missing, and the contents of the cache directory should be cleared on every run, so stale bytecode or generated configuration does not accidentally get reused. This is exactly what Composer scripts are made for: the logic lives once in the project, but runs identically and automatically for every developer and in every CI environment.

The implementation uses a small class with constructor property promotion that accepts the IOInterface and the dev-mode status and encapsulates several individual setup steps. It matters to write each step defensively: an already existing directory must not trigger an error, an already existing .env must not be overwritten, otherwise local configuration would be lost on every composer update. This restraint distinguishes a robust Composer hook from a script that destroys existing work on its second invocation.


<?php

declare(strict_types=1);

namespace Acme\Setup;

use Composer\IO\IOInterface;

/**
 * Encapsulates the individual setup steps triggered by Composer lifecycle hooks.
 */
final class ProjectSetup
{
    public function __construct(
        private readonly IOInterface $io,
        private readonly bool $isDevMode,
    ) {
    }

    /**
     * Copies .env.example to .env if no .env file exists yet.
     * Never overwrites an existing .env to avoid losing local configuration.
     */
    public function ensureEnvFile(): void
    {
        $envFile = getcwd() . '/.env';
        $exampleFile = getcwd() . '/.env.example';

        if (is_file($envFile) || !is_file($exampleFile)) {
            return;
        }

        copy($exampleFile, $envFile);
        $this->io->write('<info>.env created from .env.example</info>');
    }

    /**
     * Creates required runtime directories if they are missing.
     */
    public function ensureDirectories(): void
    {
        foreach (['var/log', 'var/cache'] as $relativePath) {
            $path = getcwd() . '/' . $relativePath;
            if (!is_dir($path)) {
                mkdir($path, 0775, recursive: true);
                $this->io->write(sprintf('<info>Created directory %s</info>', $relativePath));
            }
        }
    }

    /**
     * Removes stale cache contents after every install or update.
     */
    public function clearCache(): void
    {
        $cacheDir = getcwd() . '/var/cache';
        if (!is_dir($cacheDir)) {
            return;
        }

        foreach (glob($cacheDir . '/*') as $entry) {
            is_dir($entry) ? $this->removeDirectory($entry) : unlink($entry);
        }

        $this->io->write('<info>Cache directory cleared</info>');
    }

    /**
     * Recursively removes a directory and its contents.
     */
    private function removeDirectory(string $path): void
    {
        foreach (glob($path . '/*') as $entry) {
            is_dir($entry) ? $this->removeDirectory($entry) : unlink($entry);
        }

        rmdir($path);
    }
}

7. Composer plugins vs. Composer scripts

For simple, project-specific automation, Composer scripts are usually sufficient: they are quick to define, need no dedicated package, and live directly in the project's composer.json. But as soon as logic needs to be reused across multiple projects without copying the handler class into every single one, the script mechanism reaches its limits. A Composer script belongs to the root package and must be re-entered in every composer.json, whereas a Composer plugin is included as a dependency via composer require and activates itself automatically.

A real Composer plugin implements Composer\Plugin\PluginInterface with the methods activate(), deactivate(), and uninstall(), and optionally Composer\EventDispatcher\EventSubscriberInterface, to register itself programmatically for any lifecycle hooks, instead of via static entries in the scripts block. That pays off once the automation needs configuration options, should expose its own Composer commands via Capable and CommandProvider, or is distributed as shared tooling to multiple teams through a private Packagist repository. For a single project with manageable setup logic, however, the simple Composer script remains the more pragmatic, lower-maintenance choice.

8. Error handling in scripts

Composer handles errors in shell commands and PHP callbacks differently, but with the same outcome: a failed script aborts the entire Composer operation. For a shell command, any non-zero exit code counts as an error, just like in any other shell script. For a PHP callback, a thrown exception counts as an error, while a regular return value from the method is ignored, because the signature is invoked as void. Anyone who wants to deliberately abort inside a Composer hook must throw an exception rather than merely returning false.

Two flags globally influence the behavior of Composer scripts. --no-scripts suppresses all lifecycle hooks for a single invocation, which helps isolate whether a problem is caused by Composer itself or by a custom script, or to prevent potentially untrusted code from dependencies from running in security-critical environments. --no-dev does not directly affect whether scripts run, but it does affect whether packages from require-dev are installed and autoloadable. If the handler class of a production-relevant Composer script accidentally lives only in the dev autoload, the hook fails with a class-not-found error on a --no-dev deployment, a classic and easily avoidable configuration mistake.

9. All hook types in direct comparison

The previous sections introduced four fundamentally different mechanisms for implementing Composer automation. Which one is right depends on the scope of the task and on whether the logic stays project-specific or needs to be shared across multiple projects. The following table places shell-command scripts, PHP-callback scripts, Composer plugins, and event-dispatcher subscribers directly side by side.

Hook type Purpose Complexity When to use Advantage
Shell-command script Run individual CLI commands directly Very low Short, platform-independent command No PHP class required
PHP-callback script Project-specific setup logic Low to medium Needs access to Composer\Script\Event No child process, full context
Composer plugin Reusable automation Medium to high Sharing logic across multiple projects Automatic activation via require
Event-dispatcher subscriber Programmatic registration of many events High Plugin reacts to many events dynamically No rigid scripts block required

In practice, almost every project starts with simple Composer scripts for pre-install-cmd and post-install-cmd, and only switches to a real plugin once the automation actually outgrows the boundaries of a single repository. This step-by-step escalation, from shell command through PHP callback to a dedicated plugin, is not a sign of poor planning, it is the pragmatic normal path for Composer hooks.

10. Summary

The core value of Composer scripts and lifecycle hooks always solves the same underlying problem: manual setup steps that have to be repeated after every checkout disappear from README files and developers' memory and land, versioned, in composer.json. The four central command events pre-install-cmd, post-install-cmd, pre-update-cmd, and post-update-cmd cover most project-wide use cases, while post-autoload-dump and post-create-project-cmd add specialized moments in time.

Shell commands are suited to short, platform-independent invocations, PHP callbacks via static methods and Composer\Script\Event to everything that needs access to IO, configuration, and dev-mode status. Once automation outgrows a single repository, a real Composer plugin becomes the more appropriate choice than a Composer script that keeps getting copied around. And anyone who knows --no-scripts and the pitfalls of --no-dev avoids the most common sources of error where Composer hooks meet deployment pipelines.

Composer Scripts and Lifecycle Hooks, the Essentials at a Glance

Event categories

Command events like post-install-cmd for project-wide automation, package events for Composer plugins.

Handler types

Shell command as a string for short invocations, static PHP callback with Composer\Script\Event for everything else.

Custom commands

The scripts block also works as an alias mechanism, callable via composer run-script or the short form.

Plugins instead of scripts

As soon as logic needs to be shared across projects, a Composer plugin with its own activation takes over.

11. FAQ: Composer Scripts and Lifecycle Hooks

1What are Composer scripts?
Shell commands or PHP callbacks bound in the scripts block of composer.json to lifecycle events like post-install-cmd, running automatically there.
2What Composer events exist?
pre-install-cmd, post-install-cmd, pre-update-cmd, post-update-cmd, post-autoload-dump, post-create-project-cmd, plus package events for plugins.
3Shell command or PHP callback: which is better?
Shell for short, platform-independent invocations. PHP callback for anything needing Composer\Script\Event access, without child-process overhead.
4What does Composer\Script\Event provide?
getIO(), getComposer(), isDevMode(), getName(), and getArguments(), the complete context a script runs in.
5How do I define a custom Composer command?
Add a free name in the scripts block, e.g. test: phpunit. Invoke via composer run-script test or the short form composer test.
6run-script with extra arguments?
composer run-script name -- arg1 arg2. Everything after the double dash is retrievable via getArguments() in the event object.
7When a Composer plugin instead of a script?
As soon as logic needs to be shared across projects. A plugin is pulled in via composer require and activates itself automatically.
8What happens when a script errors?
Non-zero exit code for shell commands, thrown exception for PHP callbacks. Both abort the entire Composer operation.
9What does --no-scripts do?
Suppresses all lifecycle hooks for a single invocation, useful for isolating errors or for security reasons.
10Why does a hook fail under --no-dev?
If the handler class only lives in the dev autoload, it is missing from the autoloader because require-dev packages are not installed under --no-dev.

Mironsoft

PHP architecture, automation and Magento development

Want to use Composer scripts and lifecycle hooks properly in your project?

We analyze existing setup instructions and manual onboarding steps and replace them with versioned Composer scripts and lifecycle hooks, with clean error handling and PHPStan enforcement at level 5 and above.

Code review

Analysis of existing Composer scripts for fragility and portability

Automation

Turning setup steps, cache handling and onboarding into PHP callback hooks

Plugin development

Custom Composer plugins for shared tooling across multiple projects