How to Load --config With Services in Symfony Console

This post was updated at November 2020 with fresh know-how.
What is new?

Updated config loading approach. Switched deprecated --set option to esc.php config. Switched YAML to PHP configuration.

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 ECS it's ecs.php, 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.php

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.php or app/config/config.neon. It doesn't change and every developer knows that. If we put the file to app/config.php, 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.php 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.php

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;
        $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.php
vendor/bin/ecs check src --config vendor/nette/cofing-standard/php70.php

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);

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);

Run it:

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

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->addOption('config', null, InputOption::VALUE_REQUIRED, 'Config file.');
bin/ecs check src --config custom-config.php

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 --configuration options and do you want to migrate them to this?

Then go symplify/set-config-resolver. Special package dedicated to this problem.

Happy coding!

Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!