Build Your First Symfony Console Application with Dependency Injection Under 4 Files

Series about PHP CLI Apps continues with 3rd part about writing Symfony Console Application with Dependency Injection in the first place. Not last, not second, but the first.
Luckily, is easy to start using it and very difficult to

Symfony Evolution

7 years ago it was a total nightmare to use Controllers as services. Luckily, Symfony evolved a lot in this matter and using Symfony 4.0 packages in a brand new application is much simpler than it was in Symfony 2.8 or even 3.2. The very same evolution allowed to enter Dependency Injection to Symfony Console-based PHP CLI App.

Commands as Services

I already wrote about why is this important, today we look at how to actually do it. To be clear, how to do it without the need of bloated FrameworkBundle, that is an official but rather bad-practice solution.

3 Steps to First Command as a Service

All we need are these 3 elements:

The simplest things first.

1. services.yml

Create config/services.yml with classic PSR-4 autodiscovery/autowire setup and register Symfony\Component\Console\Application as well. We will use this class later.

# config/services.yml
services:
    _defaults:
        autowire: true

    App\:
        resource: '../app'

    Symfony\Component\Console\Application:
        # why public? so we can get it from container in bin file
        # via "$container->get(Application::class)"
        public: true

2. Kernel

The basic stone of all Symfony Applications. Nothing extra here, we just load the config/services.yml from the previous step:

<?php

# app/Kernel.php

use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class AppKernel extends Kernel
{
    /**
     * In more complex app, add bundles here
     */
    public function registerBundles(): array
    {
        return [];
    }

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

There is one more thing. We'll

3. The bin file

Last but not least the entry point to our application - bin/some-app. That's basically twin-brother of public/index.php, just for CLI Apps.

# bin/some-app

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

So let's say we have a App\Command\SomeCommand with some name and we want to run it:

bin/some-app some

But we get:

Command "some" is not defined.

Why? We're sure that:

What are we missing? Oh, we forgot to load commands to the Application service. Everything works, but our application doesn't know about our commands. It's like if the web application doesn't know where to find the controller.

How to Add All Services of Type A to Service of Type B

With FrameworkBundle we'd add autoconfigure option to services.yml config - it works with tags, but here we need to use clean PHP. Tags magic that is often overused in wrong places, so this extra works is actually a good thing. We know what happens... but mainly readers of our code know it too.

This is the place to use famous collector pattern via CompilerPass:

# app/DependencyInjection/CompilerPass/CollectCommandsToApplicationCompilerPass.php

namespace App\DependencyInjection\CompilerPass;

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class CollectCommandsToApplicationCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $containerBuilder): void
    {
        $applicationDefinition = $containerBuilder->getDefinition(Application::class);

        foreach ($containerBuilder->getDefinitions() as $name => $definition) {
            if (is_a($definition->getClass(), Command::class, true)) {
                $applicationDefinition->addMethodCall('add', [new Reference($name)]);
            }
        }
    }
}

And make our Kernel aware of it:

# app/Kernel.php

// ...

use App\DependencyInjection\CompilerPass\CollectCommandsToApplicationCompilerPass;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;

// ...

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

    // ...
}

This will compile to container to something like this:

function createSomeCommand()
{
    return new SomeCommand();
}

function createApplication()
{
    $application = new Application;
    $application->add(createSomeCommand());

    return $application;
}

Now let's try it again:

bin/some-app some

It works! And that's it. I told you it'll be easy - how can we not love Symfony :).

Do you still struggle with some parts? Don't worry, this post is tested by PHPUnit, so you can find all the code mentioned here - just click on "Tested" in the top of the post to see it.



Happy coding!


This Post is Tested, Make it Last Forever

The final code is tested (see it on Github) and is the best solution in time being.

Thanks to tests this code:

  • always run against the most recent dependencies
  • will get updates with just a little work
  • will be useful for as long as possible

Read more about tested posts as they're essential for post's lifetime.

Without tests the post won't make it through next major release and it's long tail effect would spread obsolete practise.

What do you think?


GitHub RSS @votrubaT Runs on Statie Hosted on GitHub

Like what I write about? Hire me & we can work together