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!


  Travis Knowns the Code Works

The code used in this post is tested daily with Travis CI. You can see tests on Github.

Thanks to tests this post:

  • always run against the most recent dependencies
  • gets updates and stays relevant for many years even when new major version of PHP or Symfony is released

Typo? Fix it, please  and join 47 people who build this website

GitHub RSS @votrubaT Runs on Statie Hosted on GitHub Build by 48 people

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