DEV Community

david duymelinck
david duymelinck

Posted on

Composer is just a console application

I want to preface the post with two remarks.

Yes adding just to the title is clickbait. It is like saying Symfony's bin/console and Laravel's artisan are just console applications.

I'm embarrassed I never took the time to understand Composer until now. I have been preaching for a long time to start each PHP project with Composer, even when the project is not going end up on Packagist.

How did I get here

I recently did an exploration of Yii3. And in the configuration file I saw something that caught my eye.

// NOTE: After making changes in this file, run `composer yii-config-rebuild` to update the merge plan.
Enter fullscreen mode Exit fullscreen mode

So I dug deeper and found out the command is part of the Yii3 config component. I will explain later how they did it, but if you are curious try to find out for yourself. This post will still be here when you get back.

The second trigger were @suckup_de itp-context library's cli commands.
Having to use vendor/bin/itp-context-summarize and vendor/bin/itp-context-validate triggered my developer laziness. At first my thoughts went to creating a console application like console/bin. But then you still need it to put it in the vendor bin and the command would be vendor/bin/itp-context summarize. This only helps with the discoverability of the commands not with typing less.

The Composer plugin way

Composer allows you to add commands using plugins.

I'm going to show how it is done using the vendor/bin/itp-context-summarize command.

The first step might be a hurdle for some people but the commands should be a project on their own. The main reason is that for a composer plugin to work the type in composer.json has to be composer-plugin.

{
    "name": "voku/itp-context-commands",
    "type": "composer-plugin",
    "require": {
        "composer-plugin-api": "^2.0"
    },
    "require-dev": {
        "composer/composer": "^2.0"
    },
    "autoload": {
       "psr-4": {
         "ItpContextCommands\\": "src/"
       }
    },
    "extra": {
        "class": "ItpContextCommands\\Plugin"
    }
}
Enter fullscreen mode Exit fullscreen mode

The plugin code is very simple.

class Plugin implements PluginInterface, Capable
{
    public function activate(Composer $composer, IOInterface $io)
    {
        // void
    }
    public function getCapabilities(): array
    {
        return [CommandProvider::class => ItpContextCommandProvider::class];
    }

    /**
     * @inheritDoc
     */
    public function deactivate(Composer $composer, IOInterface $io)
    {
        // void
    }

    /**
     * @inheritDoc
     */
    public function uninstall(Composer $composer, IOInterface $io)
    {
        // void
    }
}
Enter fullscreen mode Exit fullscreen mode

The ItpContextCommandProvider is even more simple.

class ItpContextCommandProvider implements CommandProvider
{

    /**
     * @inheritDoc
     */
    public function getCommands()
    {
        return [
            new SummarizeCommand(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Now that the bootstrapping is done, the work on the actual commands can begin.

use Composer\Command\BaseCommand;

#[AsCommand('itp-context:summarize')]
class SummarizeCommand extends BaseCommand
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // the real code
        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

You might have noticed the BaseCommand as parent instead of Command or no parent at all. This is a Composer restriction.

Now the library can add voku/itp-context-commands to the composer require section. And when the library is installed Composer will ask the question that the plugin may be used. When you allowed it, you can use composer itp-context:summarize or composer itp:summarize. And that makes my developer lazy part happy.

Thinking bigger

Most PHP solutions have a console application, bin/console and artisan are the most known. Yii has yii. Statamic has please next to artisan.
And that got me thinking, what if you could merge those applications with composer to have a single point of entry for all commands.

For this to work the Composer plugin API could extend the CommandProvider interface with a getApplication method.

For Symfony the CommandProvider child could be something like.

use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application as SymfonyApplication;
use Symfony\Component\Console\Application;

class SymfonyCommandProvider implements CommandProvider
{

    public function getCommands(): array
    {
        return [];
    }

    public function getApplication(): Application|null
    {
       $kernel = new Kernel(getenv('APP_ENV'), (bool) getenv('APP_DEBUG'));

       return new SymfonyApplication($kernel);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Composer Application could then have a getPluginApplicationCommands method. A naive implementation could be.

public function getPluginApplicationCommands(): array
{
   $commands = [];

   $composer = $this->getComposer(false, false);
     if (null === $composer) {
        $composer = Factory::createGlobal($this->io, $this->disablePluginsByDefault, $this->disableScriptsByDefault);
     }

     if (null !== $composer) {
        $pm = $composer->getPluginManager();
        foreach ($pm->getPluginCapabilities('Composer\Plugin\Capability\CommandProvider', ['composer' => $composer, 'io' => $this->io]) as $capability) {
           $application = $capability->getApplication();
           // ConsoleApplication is alias of Symfony\Component\Console\Application
           if($application instanceof ConsoleApplication) {
              $commands = $application->all();
           }
        }
    }

    return $commands;          
}
Enter fullscreen mode Exit fullscreen mode

And that method can then be used to add the commands.

The biggest problem with the change at this moment is that the commands from other applications can overwrite the Composer commands.
To prevent this the CommandProvider interface could be extended with the getApplicationNamespace method.
This requires the following changes.

use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application as SymfonyApplication;
use Symfony\Component\Console\Application;

class SymfonyCommandProvider implements CommandProvider
{

    public function getCommands(): array
    {
        return [];
    }

    public function getApplication(): Application|null
    {
       $kernel = new Kernel(getenv('APP_ENV'), (bool) getenv('APP_DEBUG'));

       return new SymfonyApplication($kernel);
    }

    public function getApplicationNamespace(): string
    {
       return 'symfony';
    }
}
Enter fullscreen mode Exit fullscreen mode
public function getPluginApplicationCommands(): array
{
   $commands = [];

   $composer = $this->getComposer(false, false);
     if (null === $composer) {
        $composer = Factory::createGlobal($this->io, $this->disablePluginsByDefault, $this->disableScriptsByDefault);
     }

     if (null !== $composer) {
        $pm = $composer->getPluginManager();
        foreach ($pm->getPluginCapabilities('Composer\Plugin\Capability\CommandProvider', ['composer' => $composer, 'io' => $this->io]) as $capability) {
           $application = $capability->getApplication();
           // ConsoleApplication is alias of Symfony\Component\Console\Application
           if($application instanceof ConsoleApplication) {
              $commands = $application->all();
           }

           $namespace = $capability->getApplicationNamespace();

           if( '' != $namespace) {
              foreach($commands as &$command) {
                 $command->setName($namespace . ':' . $command->getName());
              }
           }
        }
    }

    return $commands;          
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I'm wondering if there is consequence that stops us from using Composer as a centralised command entry point?
Did Symfony set the example by using a separate file for the application, and did the rest follow the example?
Why did Symfony choose to add the bin directory to the root directory of the project instead of using the bin key in composer.json?

I got a lot more questions than answers after this Composer plugin deep dive.
The good thing is that I discovered more options with a solution I use daily.

Top comments (0)