Why You Should Combine Symfony Console and Dependency Injection

I saw 2 links to Symfony\Console in today's Week of Symfony (what a time reference, huh?). There are plenty of such posts out there, even in Pehapkari community blog: Best Practice for Symfony Console in Nette or Symfony Console from the Scratch.

But nobody seems to write about the greatest bottleneck of Console applications - static cancer. Why is that?

1. Current Status in PHP Console Applications

Your web application has an entry point in www/index.php, where it loads the DI Container, gets Application class and calls run() on it (with explicit or implicit Request):

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

// Kernel or Configurator
$container = $kernel->getContainer();
$application = $container->get(Application::class);
$application->run(Request::createFromGlobals());

Console Applications (further as CLI Apps) have very similar entry point. Not in index.php, but usually in bin/something file.

When we look at entry points of popular PHP Console Applications, like:


PHP_CodeSniffer

$runner = new PHP_CodeSniffer\Runner();
$runner->runPHPCS();

PHP CS Fixer

$application = new PhpCsFixer\Console\Application();
$application->run();

PHPStan

$application = new Symfony\Component\Console\Application('PHPStan');
$application->add(new AnalyseCommand());
$application->run();

If we mimic such approach in web apps, how would our www/index.php look like?

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

$application = new Application;
$application->addController(new HomepageController);
$application->addController(new PostController);
$application->addController(new ContactController);
$application->addController(new ProducController);
// ...
$application->run();

How do you feel seeing such code? I feel a bit weird and I don't get on well with static code.

On the other hand, if we take the web app approach to cli apps:

$container = $kernel->getContainer();

$application = $container->get(Application::class);
$application->run(new ArgInput);

Why is That?

I wish I knew this answer :). In my opinion and experience with building cli apps, there might be few...

✅ Advantages

  • CLI apps almost always start with simple plain PHP code:

    # bin/turn-tabs-to-spaces.php
    
    $input = $argv[1];
    
    // 1st PSR-2 rule: replace tabs with spaces
    return str_replace('\t', ' ', $input);
    

    No container, no dependency injection, sometimes not even dependencies. Just see the PHP-CS-Fixer v0.00001.

    When the proof of concepts works, the application grows.

  • It's easy, quick and simple.

  • Who would use container right from the start of 1 command, right?

❌ Disadvantages

  • If you start a project with new static, it's difficult to migrate.
  • The need of refactoring is clear much earlier before it really happens.
  • When the application grows, new classes are added and you need to think more and more what class to pass by the constructor, which are singletons, which value objects/DTOs etc.

2. Container Inceptions

The container is slowly appearing not as the backbone of application as in web apps, but as part of commands.

E.g. AnalyseCommand in PHPStan:

use Symfony\Component\Console\Command\Command;

class AnalyseCommand extends Command
{
    // ...

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $container = $this->containerFactory->createFromConfig($input->getOption('config'));

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

Or in FixerFactory in PHP CS Fixer:

# much simplified

class FixerFactory
{
    public function registerBuiltInFixers()
    {
        static $fixers = [];

        foreach (Finder::findAllFixerClasses() as $fixerClass) {
            $fixers[] = new $fixerClass;
        }
    }
}

❌ Disadvantages

  • Well, ambiguous approach to creating service-like-classes.
  • There is an inconsistent approach to services. How do you know where to put it? Is it a service or is it a class to be created manually?
  • Should you inject dependency manually or let container (or any higher service) handle that?

✅ Advantages

  • It's better than no container at all.
  • It gives at least some basis for future refactoring.
  • Very useful for collecting of minimal basic classes: like rules in PHPStan, Fixers in PHP CS Fixer or Sniffs in PHP Code Sniffer.

Imagine a code like this in your web application:

class ProductController
{
    /**
     * @var Connection
     */
    private $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connectoin;
    }

    public function detail($id);
    {
        $productRepository = new ProductRepository($this->connection);
        $product = $productRepository->get($id);

        // ...
    }
}

How do you feel about it?

Injection Inception Problem

CLI apps authors often struggle with the question: When should be the container created?

  • In a bin file?
  • In a Command?
  • And how to get any service container outside the Command scope?
  • How to share services between 2 Commands?
  • How to avoid creating container in every single Command?

And how to create container when user provides config with services via --config option? The complexity of this question usually leads to choice 2 or 1.

I won't get into more details now, since I'll write about possible solutions in following posts.

This application cycle has these steps:

  • call bin file
  • create Application with new
  • add commands with $application->add(new SomeCommand)
  • run Application
  • in called command, there are 2 approaches
      1. create a container
      • load it with few services
      • use these services in the scope of this command
      1. create other classes with new
      • sometimes add them to the container, so they can be used later
      • sometimes add use them in scope and re-create them again when needed

Compare it to a web application:

  • call www/index.php file
  • create dependency injection container
  • get Application from it
  • run it with the current request
  • invoke controller and all other needed services in the scope of this controller

3. Symfony\Console meets Symfony\DependencyInjection

Why not inspire by web apps, where Controllers are lazy and dependency injection is the first-class citizen? Moreover, Symfony 3.4 allows Lazy Commands, that make application cycle more and more similar to web apps. Be careful - there are few WTFs during migration to Lazy Commands, as Shopsys describes.

# bin/rector

// ...

$container = $kernel->getContainer();

$application = $container->get(Application::class);
$application->run();

❌ Disadvantages

✅ Advantages

  • Web apps = CLI apps, nothing extra to learn for new contributors, even though they contribute a CLI app for their first time.
  • You can use all Symfony 3.3+ super cool features.
  • It's much easier to scale architecture than with non-container apps.
  • You can drop a lot of boilerplate code that sticks code together and simulates container, like singletons, static::$vars inside classes etc.
  • You can avoid bugs caused by unexpected behavior - like object, that looks like services but is created in 2 different places and holds different variables.

How To Migrate from 1 to 3?

I wish there was Rector for that like there is for Doctrine Repositories as Services, but it is a too complex task at the moment. Maybe one day.

In the meantime you can use few guides:


That's what works for me in CLI apps I've been working on. Look for yourself to get real code inspiration:


Which approach do you find the best in your own practice for the long-term code?



Happy injecting!




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!