11

I have a command-line application, which so far uses the Symfony dependency injection component. I now find that I want to add command-line options and improve the formatting of the output, and the Symfony console component seems like a good choice for that.

However, I can't fathom how to get my Symfony console command classes to receive the container object.

The documentation I have found uses the ContainerAwareCommand class, but that is from the FrameworkBundle -- which seems a huge amount of overhead to add to a pure CLI app, as it requires further bundles such as routing, http, config, cache, etc, none of which are of any relevance to me whatsoever here.

(Existing SO question How can i inject dependencies to Symfony Console commands? also assumes the FrameworkBundle, BTW.)

I've made a test repository here with a basic command that illustrates the problem: https://github.com/joachim-n/console-with-di

Community
  • 1
  • 1
joachim
  • 28,554
  • 13
  • 41
  • 44

3 Answers3

7

Symfony 3/4/5 Way

Since 2018 and Symfony 3.4+ DI features, you can make use of commands as services.

You can find working demo here, thanks to @TravisCarden

In short:

1. App Kernel

<?php

# app/Kernel.php

namespace App;

use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class AppKernel extends Kernel
{
    public function registerBundles(): array
    {
        return [];
    }

    public function registerContainerConfiguration(LoaderInterface $loader): void
    {
        $loader->load(__DIR__.'/../config/services.yml');
    }

    protected function build(ContainerBuilder $containerBuilder): void
    {
        $containerBuilder->addCompilerPass($this->createCollectingCompilerPass());
    }

    private function createCollectingCompilerPass(): CompilerPassInterface
    {
        return new class implements CompilerPassInterface
        {
            public function process(ContainerBuilder $containerBuilder)
            {
                $applicationDefinition = $containerBuilder->findDefinition(Application::class);

                foreach ($containerBuilder->getDefinitions() as $definition) {
                    if (! is_a($definition->getClass(), Command::class, true)) {
                        continue;
                    }

                    $applicationDefinition->addMethodCall('add', [new Reference($definition->getClass())]);
                }
            }
        };
    }
}

2. Services

# config/services.yml

services:
    _defaults:
        autowire: true

    App\:
        resource: '../app'

    Symfony\Component\Console\Application:
        public: true

3. Bin File

# index.php

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

use Symfony\Component\Console\Application;

$kernel = new AppKernel;
$kernel->boot();

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

Run it

php index.php

If you're interested in a more detailed explanation, I wrote a post Why You Should Combine Symfony Console and Dependency Injection.

Tomas Votruba
  • 23,240
  • 9
  • 79
  • 115
  • 1
    This looks like a great solution! Does it need to be changed for Symfony 4? I tried it and got an exception saying that it can't find a service definition for `Application`. – TravisCarden Oct 18 '18 at 14:31
  • Thanks! Nono, you just need to register `Symfony\Component\Console\Application` in your config. I've updated the answer – Tomas Votruba Oct 18 '18 at 14:43
  • 1
    Thank you, @tomáš-votruba, but I still to get the same error. I've created a proof of concept based on your answer and created a GitHub repo for it at https://github.com/TravisCarden/stackoverflow-a-50356503. Would you care to look at it to see what's wrong? Perhaps we can validate a total solution and update the answer accordingly. – TravisCarden Oct 23 '18 at 06:56
  • Thanks for the repo! I've just tried it locally and after 30 minutes I finally face-palmed. I'm terribly sorry, the definitions are not available until the compiler pass. I've updated the code to make it work locally. – Tomas Votruba Oct 23 '18 at 07:48
  • 1
    Thank you, @tomáš-votruba. I've updated the repo to reflect your latest edit. The change makes sense, but I still get a fatal error. I wonder if we're losing something in translation, as it were. Would you mind just sending a pull request to my repo? – TravisCarden Oct 23 '18 at 16:48
  • 1
    Check it: https://github.com/TravisCarden/stackoverflow-a-50356503/pull/1 I've also added link to the repository demo to the answer, if that's ok with you. – Tomas Votruba Oct 23 '18 at 17:19
  • Kernel in newer symfonies require env and debug options before it can be created. – przemo_li Nov 06 '19 at 09:10
  • This works great for the most part, but I'm wondering how to best test commands you build this way. Normally using CommandTester, your test cases extend from Symfony's KernelTestCase, which is provided by the Framework Bundle. Running tests without booting the kernel and reinventing KernelTestCase are equally unattractive solutions :) Anyone have suggestions? – Dane Powell Jun 05 '20 at 21:54
  • Command, Controller and EventSubscribers are just delegators. They should not contains any logic, just get a requires, commmand name or event name and then call a service. So if you have a command, test the service it calls :) – Tomas Votruba Jun 06 '20 at 09:19
  • I wanted to add Event Listeners via services config, rather than via the bin file, so they could be tested using ApplicationTester. This required adding Event Listeners via the compiler pass. Not sure this is 100% correct but it seems to work: https://github.com/acquia/cli/pull/172 – Dane Powell Jun 18 '20 at 00:51
3

Yes, the whole framework isn't required. In your case, first you need to create a kind of entry script. Something like that:

<?php

require 'just/set/your/own/path/to/vendor/autoload.php';

use Symfony\Component\Console\Application;
use Symfony\Component\DependencyInjection\ContainerBuilder;

$container = new ContainerBuilder();
$container
    ->register('your_console_command', 'Acme\Command\YourConsoleCommand')
    ->addMethodCall('setContainer', [new Reference('service_container')]);
$container->compile();

$application = new Application();
$application->add($container->get('your_console_command'));
$application->run();

In this example, we create the container, then register the command as a service, add to the command a dependency (the whole container in our case -- but obviously you can create another dependency and inject it) and compile the container. Then we just create app, add the command instance to the app and run it.

Sure, you can keep all configurations for container in yaml or xml or even using PHP format.

E.K.
  • 1,045
  • 6
  • 10
0

Four years after, but intended for somebody looking for a similar solution. I had a look alike scenario and implemented my own boilerplate with Dependency Injection ready to go for a standalone symfony console application, supporting auto-wiring and auto-configure for commands and services.

composer create-project coral-media/crune crune

A sample command receiving the container it's in place. Source code available at Github

Happy Coding!

rernesto
  • 554
  • 4
  • 11