How to Load --config With Services in Symfony Console

PHP CLI apps usually accept config, to setup their behavior. For PHPUnit it's phpunit.xml, for PHP CS Fixer it's .php_cs, for EasyCodingStandard it's easy-coding-standard.yml, for PHPStan it's phpstan.neon and so on.

In the first post about PHP CLI Apps I wrote about poor DI support in PHP CLI projects.

Today we look on the first barrier that leads most people to prefer static over DI - how to load config with services.
vendor/bin/phpstan --configuration phpstan.neon
vendor/bin/ecs --config ecs.yml

Can you spot the difference? Same CLI input, but:

Today you'll learn how to get from first to second, knowing why and all the pros and cons.

Who Comes First?

This addresses a problem (or rather mind-exercise) of injection inception aka chicken vs. egg. Because this might be a little bit confusing, I try to describe it in 3 different forms:

A. In Chicken vs. Egg Form

We need an egg, so we can create a chicken. We can get an egg thanks to a chicken. With this egg, we can create a chicken. Then we need to get a chicken to "cluck".

B. In Container vs. Config Form

We need a config to create a container. We can get a config thanks to Symfony\Component\Console\Application. With this value, we can create a container. Then we need to get a Symfony\Component\Console\Application service and call run() method on it.

C. In Implementation Form

Are you lost? That's all right. Let's see it in a list:

Very nice recursion, isn't it?

Why this Problem Even Exists?

To get the main config in PHP App is easy. Symfony has a common path in Kernel, Nette in Configurator and other frameworks likewise.

It's usually absolute path defined in PHP code, usually app/config/config.yml or app/config/config.neon. It doesn't change and every developer knows that. If we put the file to app/config.yml, it won't be loaded. PHP Apps are nice and clear in this matter.

PHP CLI Apps are Free

Users can configure the path to the main config, they can have multiple configs, .dist configs, config located in the root or nested in /config directory, it can be named my-own-super-cool-config.yml and so on.

Legacy bound architecture design or static code is a price for the freedom we have to pay here. So can we pay less?

3 Possible "Solutions"

Imagine we call EasyCodingStandard with following --config:

vendor/bin/ecs --config some-config.yml

1. The Mainstream: No Container

Use static approach, no services config, just list of items. Most spread solution so far.

How if Fits?

Ready in 2 minutes

Well, static

2. DI for Poor People: Container in a Command

I'm used to container thanks to great work of David Grudl and many posts he wrote about dependency injection, so this one is very counter-intuitive to me, but I still see it quite often in the wild.

The easiest way to start using Container in a static application is to create it at the class we need it:

use Symfony\Component\Console\Command\Command;

class SomeCommand extends Command
{
    // ...

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $containerBuilder = new ContainerBuilder;
        $containerBuilder->addConfig($input->getOption('config'));
        $container = $containerBuilder->build();

        $someService = $container->get(SomeService::class);
        // ...
    }
}

How if Fits?

Ready in 10 minutes

Only local scope, we need to re-create container everywhere we need it

The Chicken vs. Egg problem still remains very clear

3. Kill the Egg: The bin File Tuning

When I worked on the first version of nette/coding-standard almost a year ago, David came with question: "how to use ECS with 2 different configs - one for PHP 5.6 and one for PHP 7.0"?

vendor/bin/ecs check src --config vendor/nette/cofing-standard/php56.yml
vendor/bin/ecs check src --config vendor/nette/cofing-standard/php70.yml

I had no idea. So I created this issue at Symplify and praised the open-source Gods, because current version of bin/ecs was as simple as:

# bin/ecs

require_once __DIR__ . '/../vendor/autoload.php';

$container = (new ContainerFactory)->create();

// ...

$application = $container->get(Symfony\Component\Console\Application::class);
$application->run();


So what now?

ArgvInput to the Rescue

See pull-request #198

Do you know ArgvInput class? It's a Symfony\Console input helper around native PHP $_SERVER['argv'], that holds all the arguments --options 1 passed via CLI.

Let's use it:

$config = null;
$argvInput = new Symfony\Component\Console\Input\ArgvInput;
if ($argvInput->hasParameterOption('--config')) {
    $config = $argvInput->getParameterOption('--config');
}

if ($config) {
    $container = (new ContainerFactory)->createWithConfig($config);
} else {
    $container = (new ContainerFactory)->create();
}

$application = $container->get(Symfony\Component\Console\Application::class);
$application->run();

Run it:

bin/ecs check src --config custom-config.yml

And see how all nicely works on the 1st run:

Or not. Oh, it looks like we need to add config option to the CheckCommand definition:

 final class CheckCommand extends Command
 {
     // ...

     protected function configure(): void
     {
         $this->setName('check');
         // ...
+        $this->addOption('config', null, InputOption::VALUE_REQUIRED, 'Config file.');
     }
 }

So far so good.


Later, a ShowCommand was added:

bin/ecs show --config custom-config.yml

And we see our old friend again:

What now? Add the config option to the ShowCommand definition?

 final class ShowCommand extends Command
 {
     // ...

     protected function configure(): void
     {
         $this->setName('show');
         // ...
+        $this->addOption('config', null, InputOption::VALUE_REQUIRED, 'Config file.');
     }
 }

Wait a bit... Can you smell it?

Duplicated code

Not-lazy

Creates memory lock to 1 command → 1 extra operation. If we forget this, it will break.


What else can we do to solve this once and for all and release our memory to work on more important things?

Common Options to all Commands

Let me think... I've seen something like this before:

vendor/bin/ecs check /src -v
vendor/bin/ecs show -v

Where is this -v option from and how does it work in every command even though we don't define it there?

After a bit of in-PHPStorm-Googling I found this:

namespace Symfony\Component\Console;

class Application
{
    // ...

    protected function getDefaultInputDefinition()
    {
        return new InputDefinition(array(
            new InputArgument('command', InputArgument::REQUIRED, 'The command to execute'),
            new InputOption('--help', '-h', InputOption::VALUE_NONE, 'Display this help message'),
            new InputOption('--quiet', '-q', InputOption::VALUE_NONE, 'Do not output any message'),
            new InputOption('--verbose', '-v|vv|vvv', InputOption::VALUE_NONE, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'),
            new InputOption('--version', '-V', InputOption::VALUE_NONE, 'Display this application version'),
            new InputOption('--ansi', '', InputOption::VALUE_NONE, 'Force ANSI output'),
            new InputOption('--no-ansi', '', InputOption::VALUE_NONE, 'Disable ANSI output'),
            new InputOption('--no-interaction', '-n', InputOption::VALUE_NONE, 'Do not ask any interactive question'),
        ));
    }
}

So maybe we could extend this class and our --config option:

namespace Symplify\EasyCodingStandard\Console;

use Symfony\Component\Console\Application as SymfonyApplication;
use Symfony\Component\Console\Input\InputOption;

final class Application extends SymfonyApplication
{
    protected function getDefaultInputDefinition()
    {
        $inputDefinition = parent::getDefaultInputDefinition();

        // adds "--config" option
        $inputDefinition->addOption(new InputOption('config', null, InputOption::VALUE_REQUIRED, 'Config file.'));

        return $inputDefinition;
    }
}

(Btw, addOption() is just a helper method for new InputOption().)

And use our Application in bin/rector:

 // ...

-$application = $container->get(Symfony\Component\Console\Application::class);
+$application = $container->get(Symplify\EasyCodingStandard\Console\Application::class);
 $application->run();

And let's try this again:

bin/ecs check src --config custom-config.yml

How if Fits?

Ready in 15 minutes

Setup & Forget

Much more legacy-proof

We can now use Dependency Injection everywhere we need

And make use off Symfony, Nette or any other container features we're used to from Web Apps.


Where to Go Next?

Do you have a CLI App and do you find DI approach useful? Do you have --config or -c or --configuratoin options and do you want to migrate them to this? Or maybe you have --level option, that shortens the config path in some way:

vendor/bin/ecs check src --level clean-code

Yes? Then go check:

...and stay tuned for next post about CLI Apps in PHP.



Happy CLInjecting!



Do you want more on this topic? You might like these related posts: Why You Should Combine Symfony Console and Dependency Injection

What do you think?


GitHub RSS @votrubaT Like what I write about? Hire me & we can work together