Symfony2: Console Commands as Services - Why?
Matthias Noback
As you may have read on the Symfony blog: as of Symfony 2.4 you can register console commands using the service tag console.command
.
What is the big deal, you would say. Well, let’s discover it now. This is how the documentation tells you to write your console commands:
namespace Matthias\ConsoleBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class MyCommand extends ContainerAwareCommand
{
protected function configure()
{
$this->setName('my:action');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
// do something
}
}
How to make sure your command will be noticed?
Until Symfony 2.4 there were two requirements for your command to be picked up by the console application itself:
-
Your command class should extend
Symfony\Component\Console\Command\Command
-
Your command class should be placed inside the
Command
directory (which means also in theCommand
namespace) of your bundle, in a file with the same name as the class.
Following these rules, it would indeed be possible to register all the available commands, which in reality goes like this:
First, in app/console
an instance of Symfony\Bundle\FrameworkBundle\Console\Application
is created. This object is aware of the service container, your application’s kernel (e.g. AppKernel
) and the bundles that are registered inside the kernel. At the end of the file, Application::run()
is called:
$kernel = new AppKernel($env, $debug);
$application = new Application($kernel);
$application->run($input);
Then inside Application::run()
all registered bundles are asked to register their commands:
class Application extends BaseApplication
{
public function doRun(InputInterface $input, OutputInterface $output)
{
...
$this->registerCommands();
...
}
protected function registerCommands()
{
foreach ($this->kernel->getBundles() as $bundle) {
if ($bundle instanceof Bundle) {
$bundle->registerCommands($this);
}
}
}
}
Then each bundle can register any command on its own, but most bundles extend the abstract Bundle
class which contains the default implementation for registering commands:
// by the way, an abstract class should have "Abstract" as a prefix, right?
abstract class Bundle extends ContainerAware implements BundleInterface
{
public function registerCommands(Application $application)
{
if (!is_dir($dir = $this->getPath().'/Command')) {
return;
}
$finder = new Finder();
$finder->files()->name('*Command.php')->in($dir);
$prefix = $this->getNamespace().'\\Command';
foreach ($finder as $file) {
$ns = $prefix;
if ($relativePath = $file->getRelativePath()) {
$ns .= '\\'.strtr($relativePath, '/', '\\');
}
$r = new \ReflectionClass($ns.'\\'.$file->getBasename('.php'));
if ($r->isSubclassOf('Symfony\\Component\\Console\\Command\\Command') && !$r->isAbstract() && !$r->getConstructor()->getNumberOfRequiredParameters()) {
$application->add($r->newInstance());
}
}
}
}
So this is where the magic happens! My clean code eyes don’t like this at all, but nevertheless: what happens inside this method?
-
The
Finder
(yes, theFinder
!) is used to find files ending with “Command.php” in the directory “Command”. -
It tries to load the class inside this file, assuming that its name will correspond to the file name (it should, considering PSR-0).
-
It checks using reflection if the class is an instance of the
Command
class I mentioned earlier. -
It makes sure that the class is not an abstract class.
-
It also makes sure that its constructor has no required arguments.
-
If this is not the case, it can be instantiated, so: it creates an instance of the class.
Bad bundle!
I really dislike all of this, because:
-
All this code is executed each time you run any console command, which is obviously overkill.
-
All commands from the entire application are being instantiated every time.
-
This code violates the open closed principle in that there may be situations where a command is accidentally registered, or not registered when it was intended to be. These corner cases will result in more conditions being added (what about private constructors for instance?).
-
In fact, the whole command class is being robbed of its creation logic. The only way such an object may be created is using
newInstance()
, which is the same asnew ClassName()
. -
I am supposed to put my commands in the
Command
directory, but what if one of my commands is not Symfony-specific (like the Doctrine commands) and I want to import it from some PHP library package?
I think it is possible to refactor the registerCommands()
method so that number 1 is no problem anymore. I think 2 is a design problem, which is not easy to solve, since you can only know the name of a command and its arguments when you have an instance of it and you can call its getName()
method. So commands can never be truly lazy-loading.
Number 3 is not such a big problem anymore, now that you have your own way to register commands (i.e. using the console.command
service tag, since this means that you already have to make sure that a service does not point to an abstract class, or has constructor arguments which may not be supplied. This also solves number 4, since all creation logic is entirely in your own hands again. Number 5 is also no problem anymore: you can just create a new service for the command that exists outside of your bundle.
As a matter of fact, I have been registering my commands manually since some time now, which skips all the not-so-nice code in the regular Bundle
class:
namespace Matthias;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Matthias\ConsoleBundle\Command\MyCommand;
class ConsoleBundle extends Bundle
{
public function registerCommands(Application $application)
{
// ah, nice and clean!
$application->add(new MyCommand());
}
}
Coupling…
All seems well! But it isn’t… As you may have seen in the sample command class above: it extends ContainerAwareCommand
. This means that inside the execute()
method you can take any service you like from the service container:
class MyCommand extends ContainerAwareCommand
{
protected function configure()
{
$this->setName('my:action');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$someService = $this->getContainer()->get('some_service');
// $someService is expected to be an instance SomeService
$someService->doSomething();
}
}
As you can imagine: this couples your command horribly to the Symfony2 service container. But there is nothing to a console command that requires a service container really. You may do everything inline, you may even use another service container/locator from another vendor. The only thing you need in this particular command is an instance of SomeService.
Making your console command a service using the new service tag does not make any difference when it comes to this kind of coupling. It just skips all the ugly digging around in directories and files (or in fact, it just adds another way to this, it will still take a look in the Command
directory!).
… and decoupling
What can you do, to make your commands cleaner, less coupled to the framework? This!
use Symfony\Component\Console\Command\Command
class MyCommand extends Command
{
private $someService;
public function __construct(SomeService $someService)
{
$this->someService = $someService;
parent::__construct();
}
protected function configure()
{
$this
->setName('my:action');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->someService->doSomething();
}
}
We know now that $this->someService
is an instance of SomeService
. We don’t need a service container anymore, since dependencies are injected nicely via constructor arguments. We only need to register the command like this:
<service id="my_command" class="Matthias\ConsoleBundle\MyCommand">
<argument type="service" id="some_service" />
<tag name="console.command" />
</service>