Composer Package Plugin

Submitted by Jeff on

Problem:

Need a way to copy files from a package that is installed into a project. The files are either for a distributed configuration or an embedded NPM package.

Solution: 

This is a basic file copy that will take information from an "extra" composer.json entries to copy files from a sub package to the project.

To start we will need a composer.json file with some composer plugin features. According to the documentation at https://getcomposer.org/doc/articles/plugins.md, you just need these basic features:

  • Need a "type" of "composer-plugin".
  • Need a "extra" element of "class" that points to the class that will start your plugin.
  • Need a entry in the "require" with the "composer-plugin-api" with the current version which is "^1.1".
  • Not needed, but useful for programming in a IDE, I would add a "require-dev" entry with "composer/composer" with the current version or "dev-master".

 

{
    "name": "vendor/plugin-package",
    "type": "composer-plugin",
    "require": {
        "composer-plugin-api": "^1.1"
    },
    "extra": {
        "class": "Vendor\\Plugin"
    }
}

After you have that built you need to build the class that you just declared in the composer.json. The requirements for this class are:

  • Needs implement Composer\Plugin\PluginInterface
  • The implementation requires an activate function

 

<?php
namespace Vendor;

class Plugin implements \Composer\Plugin\PluginInterface
{

    public function activate(\Composer\Composer $composer, \Composer\IO\IOInterface $io)
    {
        $io->write('Hello World!!!');
    }
}

There you have the bare basics. With this you can print at the beginning of the composer process "Hello World!!!" or what ever you want it to say. However this isn't that practical, we need to be able to capture some configuration information from the project and from the packages that are installed. We are only going to worry about the install, as the update and remove functions require a bit more programming that I want to type.

So to get the configuration from the project we will update the "activate" function. Using the Composer\Composer object we can get a Composer\Package\RootPackageInterface object that we will be able to use to get some configuration information from the project's composer.json file. We will want to store that information in a static variable so we can access it later. Here is how we will change things:

 

<?php
namespace Vendor;

class Plugin implements \Composer\Plugin\PluginInterface
{
    static protected $projectExtra;

    public function activate(\Composer\Composer $composer, \Composer\IO\IOInterface $io)
    {
        $package = $composer->getPackage();
        self::$projectExtra = $package->getExtra();
    }
}

Ok, with this we are now able to capture and store the "extra" section of the projects composer.json file. The resulting variable will be an array as it converts the JSON to an array. Now we need to get information from the project to be able to know where to copy from. To do that we are going to add another interface to our object so we can look for events. With these events we will be able to grab the package that is being installed and be able to work on getting things copied. 

So that interface what we need to add is Composer\EventDispatcher\EventSubscriberInterface. With that we will add a public static function getSubscribedEvents. Then we will use Composer\Installer\PackageEvents constants to subscribe to the events. We will also need a static function to be called for the event. Here is what that will look like:

<?php
namespace Vendor;

class Plugin implements \Composer\Plugin\PluginInterface, \Composer\EventDispatcher\EventSubscriberInterface
{
    static protected $projectExtra;

    public function activate(\Composer\Composer $composer, \Composer\IO\IOInterface $io)
    {
        $package = $composer->getPackage();
        self::$projectExtra = $package->getExtra();
    }

    static public function getSubscribedEvents(): array
    {
        return [
            \Composer\Installer\PackageEvents::POST_PACKAGE_INSTALL => "postPackageInstall",
        ];
    }

    static public function postPackageInstall(\Composer\Installer\PackageEvent $event)
    {
        //Runs after a package has been installed.
    }
}

With a few more lines and we will have a simple copier. Next steps is to get the configuration from the package's composer.json file and then copy what needs to be copied. This is where I had a few issues with the documentation, in the API documentation the Composer\Installer\PackageEvent has a getComposer which in turn has a getPackage functions . This package is still the root project's composer.json, not the package that is getting installed. There isn't much in line of a getPackage in the event either, so after doing some searching (googling) and some playing, I figured out what the getOperation documentation means by "Returns the package instance" even though the return type is an OperationInterface. However the object that does get returned does have a getPackage function that will return the installed package. From that we can get the "extra" entries for the installed package.

So we now have a way to get the configuration we need to work on the copying part. While we can use to copy functions of PHP or even the system, but it would make it easier to test if we used an object. The Composer API has such an object that will allow us to copy files or directories from one location to another. Here we will use the Composer\Util\Filesystem object which has a copy function. It handles to directories that do not exist.

Since working directory is the project's root directory getcwd gets the projects directory. The package object doesn't have any information on what directory the package was installed in. So the directory of the package will need to be "calculated" by getting the name of the package and adding vendor to the name to get the relative directory of the package. We will assume that the information in the given in the composer.json file will be relative to the package or project's directory.

Here is what all of that looks like:

static public function postPackageInstall(\Composer\Installer\PackageEvent $event)
{
    $package = $event->getOperation()->getPackage();
    $name = $package->getName();
    $extra = $package->getExtra();
    if ((array_key_exists('copy', $extra)) && (array_key_exists('copy', self::$projectExtra))) {
        $from = getcwd().'/vendor/'.$name.$extra['copy'];
        $to = getcwd().$extra['copy'];
        $filesystem = new \Composer\Util\Filesystem();
        $filesystem->copy($from, $to);
    }
}

As you can see you might want to do some checks to see if the package even has what you need to do the copy otherwise you end up with errors. In this one we look for a copy element in the "extra" section to know what to copy from and to. The way this is setup we have given the project some control on where it should put the files. So if we had a Javascript file that needs to be in a public folder. Here is what the "extra" section of the composer.json files would look like:

//Project composer.json
...
    "extra": {
        "copy": "/public/js"
    }
...


//Package composer.json
...
    "extra": {
        "copy": "/frontend/dist/js"
    }
...

As you can see this is a pretty limited plugin but it should give you a starting point on your projects.

Tags