Custom Composer Plugin Development: PluginInterface, Capabilities and Events
AI generated
<?php
8.4
PHP · Composer · Package Ecosystem
Custom Composer Plugin Development
PluginInterface, events and custom installers in detail

Composer itself is extensible through its own plugin system, the same architecture that powers tools such as Symplify MonorepoBuilder or Magento component installers. Anyone building a custom Composer plugin can react to lifecycle events, register their own console commands, and even implement custom package installation logic for user defined package types.

18 min read PluginInterface · Events · Capabilities · Installer PHP 8.4 · Composer 2.x

1. What a Composer plugin really is

A Composer plugin is a regular Composer package of type plugin that provides a class implementing Composer\Plugin\PluginInterface. As soon as such a package is installed as a dependency, Composer automatically loads the specified class at startup and calls its activate method. From that point on, the plugin can listen to internal Composer events, contribute its own console commands, or even take over the entire installation of certain package types itself.

The decisive difference from a simple Composer script, which merely runs a shell command or a static PHP method at a fixed lifecycle point, lies in the depth of integration. A Composer plugin runs in the same PHP process as Composer itself, has access to the complete Composer object model, the IO handler for console output and the event dispatcher, and can therefore implement far more complex automation than a simple script.

Well known examples of Composer plugins in practice are composer/installers, which copies packages for dozens of CMS and framework directory structures to the right location, or hirak/prestissimo, which used to enable parallel downloads before that feature was built into Composer 2 directly. symplify/monorepo-builder also uses plugin mechanisms internally to provide additional console commands.

2. Basic scaffold: PluginInterface and the composer.json plugin type

A minimal Composer plugin consists of two parts: a composer.json with type plugin and an extra.class entry specifying the fully qualified class name of the plugin class, plus the actual PHP class implementing PluginInterface. This interface requires three methods: activate, deactivate and uninstall, each with access to the Composer object and the IOInterface handler for output.

The activate method runs on every Composer invocation as soon as the plugin is installed, not just once during installation itself. This is a common beginner mistake: anyone placing one time logic in activate, such as creating a configuration file, ends up re running that logic on every composer install and composer update. For actual one time actions on first install, the PackageInstalled or PostInstall events are the relevant choice instead.


{
  "name": "mironsoft/composer-audit-plugin",
  "type": "composer-plugin",
  "require": {
    "php": "^8.4",
    "composer-plugin-api": "^2.0"
  },
  "require-dev": {
    "composer/composer": "^2.7"
  },
  "autoload": {
    "psr-4": {
      "Mironsoft\\ComposerAuditPlugin\\": "src/"
    }
  },
  "extra": {
    "class": "Mironsoft\\ComposerAuditPlugin\\AuditPlugin"
  }
}

<?php

declare(strict_types=1);

namespace Mironsoft\ComposerAuditPlugin;

use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;

final class AuditPlugin implements PluginInterface
{
    public function activate(Composer $composer, IOInterface $io): void
    {
        $io->write('<info>[audit-plugin] activated</info>');
    }

    public function deactivate(Composer $composer, IOInterface $io): void
    {
        // Called when the plugin is disabled or removed
    }

    public function uninstall(Composer $composer, IOInterface $io): void
    {
        // Called on full removal — clean up any generated files here
    }
}

3. Reacting to Composer events

For the actual automation logic, a Composer plugin additionally implements the Composer\EventDispatcher\EventSubscriberInterface interface, the same PSR-14 like idea found in many other PHP FIG standards, though with Composer's own event class instead of PSR-14 itself. The getSubscribedEvents method returns an associative array mapping event names such as ScriptEvents::POST_INSTALL_CMD or PackageEvents::POST_PACKAGE_INSTALL to method names of the plugin's own class.

Relevant events for a Composer plugin cover the entire lifecycle: PRE_DEPENDENCIES_SOLVING before version resolution, PACKAGE_INSTALL and PACKAGE_UPDATE for individual packages, POST_INSTALL_CMD and POST_UPDATE_CMD after the complete command finishes. A plugin that automatically runs a security check against known CVEs after every installation, for example, hooks into POST_INSTALL_CMD and POST_UPDATE_CMD and iterates over the installed package set from the Composer repository object.


<?php

declare(strict_types=1);

namespace Mironsoft\ComposerAuditPlugin;

use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;

final class AuditEventSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            ScriptEvents::POST_INSTALL_CMD => 'onPostInstall',
            ScriptEvents::POST_UPDATE_CMD => 'onPostInstall',
        ];
    }

    public function onPostInstall(Event $event): void
    {
        $io = $event->getIO();
        $repository = $event->getComposer()->getRepositoryManager()->getLocalRepository();

        foreach ($repository->getPackages() as $package) {
            // ... check the installed package version against a CVE database
            $io->write(sprintf('  auditing %s (%s)', $package->getName(), $package->getVersion()));
        }
    }
}

4. Capabilities: registering your own console commands

Starting with Composer 2, the Composer plugin system provides so called capabilities, an extension mechanism that lets a plugin declare additional functionality without overloading the core class itself with too many responsibilities. The most relevant capability for building your own tools is Composer\Plugin\Capability\CommandProvider, which lets a plugin register its own console commands that can then be invoked with composer your-command, exactly like composer install or composer require.

The implementation happens through getCapabilities in the plugin's main class, which returns a mapping from a capability interface to a concrete implementation class. This separation makes it possible to maintain several independent commands in separate classes, while the plugin's main class itself stays lean and merely wires up Composer and your own extensions.


<?php

declare(strict_types=1);

namespace Mironsoft\ComposerAuditPlugin;

use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;

final class AuditPlugin implements PluginInterface, Capable
{
    public function getCapabilities(): array
    {
        return [
            CommandProviderCapability::class => CommandProvider::class,
        ];
    }

    // ... activate/deactivate/uninstall from the earlier example
}

final class CommandProvider implements CommandProviderCapability
{
    public function getCommands(): array
    {
        // Registers "composer audit-full" as a new CLI command
        return [new AuditFullCommand()];
    }
}

5. Custom installer for your own package types

For use cases where packages should not end up in the standard vendor directory, but at an application specific location, for example modules in a CMS or plugins in a Magento installation, a Composer plugin implements the Composer\Installer\InstallerInterface interface. This interface requires methods such as supports, which checks whether the installer is responsible for a given package type, as well as install and getInstallPath, which determine the actual installation location.

The already mentioned composer/installers package is the reference example for this approach: it registers a custom installer that computes the appropriate target directory based on the package type, for example wordpress-plugin or magento2-module, and instructs Composer to copy the files there instead of into the generic vendor folder. A custom Composer plugin with a custom installer follows the same pattern, usually with a configuration option in the composer.json extra block to flexibly adjust the target directory.


<?php

declare(strict_types=1);

namespace Mironsoft\ComposerAuditPlugin;

use Composer\Installer\LibraryInstaller;
use Composer\Package\PackageInterface;

final class RuleSetInstaller extends LibraryInstaller
{
    public function supports(string $packageType): bool
    {
        // Only handle packages explicitly declared as "mironsoft-ruleset"
        return $packageType === 'mironsoft-ruleset';
    }

    public function getInstallPath(PackageInterface $package): string
    {
        // Install into a dedicated directory instead of vendor/
        return 'rulesets/' . $package->getPrettyName();
    }
}

6. Reading plugin configuration from the extra section

A flexible Composer plugin typically reads project specific settings from the extra block of the root composer.json, accessible through $composer->getPackage()->getExtra(). A common approach is a dedicated namespace within extra, for example extra.mironsoft-audit-plugin, to avoid naming collisions with other plugins that might also store settings in the same extra block.

Important for robust Composer plugins: missing configuration must never lead to a fatal error, it should instead fall back to sensible defaults. A plugin that simply crashes without any configuration in the extra block reliably frustrates users who only installed the plugin as a transitive dependency of another library, without ever intending to configure it consciously at all.

7. Testing Composer plugins without a real installation

Composer plugins can be tested with PHPUnit without performing a real composer.json installation on every test run. The trick is to instantiate a Composer object and an IOInterface mock directly in the test and call the methods under test in isolation, instead of starting the entire Composer process. For event subscriber logic, it is usually enough to manually construct an event object with the relevant data and pass it to the corresponding method.

For integration tests that cover the real Composer lifecycle, for example to verify that a custom installer actually copies files to the right location, composer/composer as a require dev dependency together with a temporary test directory is a good fit. Such a test creates a minimal composer.json in a tmpfs directory, runs composer install programmatically through the Composer Application class, and then checks the result on disk.

8. Publishing and compatibility across Composer versions

A Composer plugin declares its compatibility through the special virtual dependency composer-plugin-api, not through a regular composer/composer dependency. This virtual dependency represents the actually installed Composer version at runtime and prevents a plugin written for Composer 1 from accidentally being loaded under Composer 2, where parts of the internal API have changed fundamentally.

The same rules apply for publishing as for any other Composer package: semantic versioning, a meaningful changelog, and a release through Packagist or Private Packagist. One important additional point specifically for Composer plugins: since they work with internal Composer API that occasionally changes between minor versions, an explicit test matrix against several Composer versions in the CI pipeline pays off, to catch breaking changes early.

9. Composer plugin vs. Composer script compared

The choice between a full fledged Composer plugin and a simple Composer script depends on the required depth of integration. The table below shows the most important differences.

Criterion Composer Script Composer Plugin Practical relevance
Setup effort One entry in composer.json Own package, own class required Important for simple automation
Access to Composer API Only through CLI calls Full access to the Composer object Relevant for deep integration
Own console commands Not possible Through the CommandProvider capability Important for own CLI tools
Reusability Copied per project Reusable as a Composer package across projects High for multiple projects
Maintenance effort Minimal Own tests, own compatibility checks Relevant for Composer major updates

For one off, project specific automation, such as clearing a cache directory after composer install, a simple Composer script is entirely sufficient. As soon as the logic needs to be reused across multiple projects, requires its own console commands, or needs to hook deeply into the Composer lifecycle, the extra effort of a full fledged Composer plugin clearly outweighs the benefits of a simple script.

Mironsoft

PHP architecture, package strategy and Composer tooling

A custom Composer plugin for your development workflow?

We build tailored Composer plugins with event handling, custom console commands and custom installer logic, tested and versioned for production use.

Concept

Assessing whether a Composer script or a full plugin is the right solution

Development

PluginInterface, event subscriber, capabilities and custom installer implemented

Tests and Release

Test matrix against several Composer versions, release through Packagist

10. Summary

Building a custom Composer plugin pays off as soon as automation goes beyond what a simple Composer script can offer: PluginInterface forms the basic scaffold with activate, deactivate and uninstall, EventSubscriberInterface allows targeted reacting to lifecycle events such as POST_INSTALL_CMD, capabilities register your own console commands, and a custom installer takes full control over the installation of your own package types.

For production use, the rules are: configuration through the extra block with sensible defaults, testing without a real installation through mocked Composer and IO objects, and an explicit compatibility check against several Composer versions in the CI pipeline. Anyone combining these building blocks builds a Composer plugin that stays robust across years and several Composer major versions.

Custom Composer Plugin Development — The Essentials at a Glance

Basic Scaffold

A composer.json of type plugin plus a class implementing PluginInterface with activate, deactivate and uninstall.

Events and Capabilities

EventSubscriberInterface for lifecycle events, CommandProvider capability for your own console commands.

Custom Installer

InstallerInterface for full control over the installation location and logic of your own package types.

Compatibility

composer-plugin-api dependency instead of a fixed Composer version, test matrix against several Composer releases.

11. FAQ: Custom Composer Plugin Development

1What exactly is a Composer plugin?
A Composer package of type plugin with a PluginInterface class that reacts to events or registers its own console commands.
2When does activate run?
On every Composer invocation, not just on first install. One time logic belongs in events such as POST_INSTALL_CMD.
3How do I react to lifecycle events?
Through EventSubscriberInterface with getSubscribedEvents, mapping event names to methods of the plugin's own class.
4How do I register my own commands?
Through the CommandProvider capability in getCapabilities, which returns a mapping to a command provider class.
5What is a custom installer?
An InstallerInterface implementation that fully takes over installation to a custom location for specific package types.
6How do I read project configuration?
Through getExtra() on the Composer package object, ideally in a dedicated namespace with sensible defaults.
7How do I test a plugin?
With PHPUnit and mocked Composer/IO objects for unit tests, optionally with composer/composer for real integration tests.
8What determines compatibility?
The virtual composer-plugin-api dependency, which represents the installed Composer version.
9When is a simple script enough?
For one off, project specific automation without needing custom commands or deep API integration, a script is sufficient.
10Can multiple commands live in one plugin?
Yes, the CommandProvider can return any number of command instances, each maintained in its own class.