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
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.
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.
All we need are these 3 elements:
service.yml
file with PSR-4 autodiscovery,The simplest things first.
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
The basic stone of all Symfony Applications. Nothing extra here, we just load the config/services.yml
from the previous step:
<?php
# app/AppKernel.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 have to do.
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:
App\Command\SomeCommand
class existsapp/Command/SomeCommand.php
fileconfig/services.yml
loads itcomposer.json
section autoload
is correctly configuredvendor/autoload.php
was dumped with composer dump
...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.
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/CommandsToApplicationCompilerPass.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 CommandsToApplicationCompilerPass 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/AppKernel.php
// ...
use App\DependencyInjection\CompilerPass\CommandsToApplicationCompilerPass;
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!
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!