Loïc Faugeron Technical Blog

Symfony2 Console component, by example 09/04/2014

Deprecated: This article has been re-written - see The Ultimate Developer Guide to Symfony - Console

TL;DR: jump to the conclusion.

Symfony2 is a set of libraries which help you in your everyday tasks. You can even stack them together and create a framework with it:

Many frameworks already use a lot of components from Symfony2:

In this article, we'll see the Console Component, which allows you to build Command Line Interface (CLI) applications. Symfony 2.5 will be released in may 2014, with great new features for the Console, so I'll speak about this version here.

Introduction

This component allows you to focus on one thing: creating commands. It takes care of all the coloring output, input gathering and command containing stuff.

The big picture is: you have an Application which contains a set of Commands. When ran, the Application will create an Input object which contains Options and Arguments provided by the user, and will feed it to the right Command.

The code being the best documentation, we'll now see the strict minimum classes you should know, with the methods you'll likely use.

Application

All you need to know about the Application is this:

<?php

namespace Symfony\Component\Console;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class Application
{
    public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN');
    public function add(Command $command);
    public function setDefaultCommand($commandName); // New in 2.5!
    public function run(InputInterface $input = null, OutputInterface $output = null);
}

Minimum usage:

#!/usr/bin/env php
<?php

use Symfony\Component\Console\Application;

$application = new Application();
$application->run();

By running this script, you should be able to see a colorful output which lists the available commands (list is the default command, and a help is also available).

Command

The Command class is the controller of your CLI application:

<?php

namespace Symfony\Component\Console\Command;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Command
{
    protected function configure();
    protected function execute(InputInterface $input, OutputInterface $output);
    protected function interact(InputInterface $input, OutputInterface $output);

    // To be called in configure
    public function setName($name);
    public function addArgument($name, $mode = null, $description = '', $default = null);
    public function addOption($name, $shortcut = null, $mode = null, $description = '', $default = null);
    public function setDescription($description);
    public function setHelp($help);
    public function setAliases($aliases);
}

Basically you create a class which extends Command. You need to implement 2 methods:

The interact method is called before the execute one: it allows you to ask questions to the user to set more input arguments and options.

Here's my stand on arguments and options modes:

Input

The container of the arguments and options given by the user:

<?php

namespace Symfony\Component\Console\Input;

interface InputInterface
{
    public function getArgument($name);
    public function getOption($name);
}

The Application validates a part of the input: it checks if the command actually accepts the given arguments and options (is the value required? Does the hello:world command have a --yell option? etc), but you still need to validate the input against your business rules (the --number option should be an integer, the name argument should be escaped to avoid SQL injection, etc).

Output

A convenient object which allows you to write on the console output:

<?php

namespace Symfony\Component\Console\Output;

abstract class Output implements OutputInterface
{
    public function writeln($messages, $type = self::OUTPUT_NORMAL);
}

The writeln method allows you to write a new line (with a newline character at the end). If the given message is an array, it will print each elements on a new line.

The tags allow you to color some parts:

ConsoleLogger

Another brand new class from the version 2.5:

<?php

namespace Symfony\Component\Console\Logger;

use Psr\Log\AbstractLogger;
use Symfony\Component\Console\Output\OutputInterface;

class ConsoleLogger extends AbstractLogger
{
    public function __construct(
        OutputInterface $output,
        array $verbosityLevelMap = array(),
        array $formatLevelMap = array()
    );

    public function log($level, $message, array $context = array());
}

As you can see, it uses the OutputInterface provided by the Application. You should inject this logger into your services, this will allow them to write messages on the standard output of the console while keeping them decoupled from this component (so you can use these services in a web environment).

Oh, and the good news is: it colors the output and decides whether or not to print it depending on the verbosity and level of log! An error message would always be printed in red, an informative message would be printed in green if you pass the -vv option.

Standalone example

Just like any other component, the Console can be used as a standalone library.

In this example, we'll create a tool which will create a LICENSE file, just like fossil (the bootstraper of markdown files for your FOSS projetcs).

Creating the application

To begin, let's install the component using Composer:

$ curl -sS https://getcomposer.org/installer | php # Downloading composer
$ ./composer.phar require "symfony/console:~2.5@dev"

Then create an empty application:

#!/usr/bin/env php
<?php
// File: fossil

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

use Symfony\Component\Console\Application;

$application = new Application('Fossil', '2.0.0');
$application->run();

Creating the command

Our command has two arguments:

It can also take the path of the project as an option (we'll provide the current directory as default value).

Let's create it:

<?php
// File: src/Gnugat/Fossil/LicenseCommand.php

namespace Gnugat\Fossil;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LicenseCommand extends Command
{
    protected function configure()
    {
        $this->setName('license');
        $this->setDescription('Bootstraps the license file of your project');

        $this->addArgument('author', InputArgument::REQUIRED);
        $this->addArgument('year', InputArgument::REQUIRED);

        $this->addOption('path', 'p', InputOption::VALUE_REQUIRED, '', getcwd());
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
    }
}

Registering the command

Our command doesn't do anything yet, but we can already register it in our application:

#!/usr/bin/env php
<?php
// File: fossil

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

use Symfony\Component\Console\Application;
use Gnugat\Fossil\LicenseCommand;

$command = new LicenseCommand();

$application = new Application('Fossil', '2.0.0');
$application->add($command);
$application->run();

In order for it to run, you'll need to register the namespace in the autoloader by editing the composer.json file at the root of the project:

{
    "require": {
        "symfony/console": "~2.5@dev"
    },
    "autoload": {
        "psr-4": { "": "src" }
    }
}

Then you need to run ./composer.phar update to update the configuration.

Using the Filesystem component

In fossil, templates are retrieved using the Finder component, their values are replaced using Twig and written using the Filesystem component.

In order to keep this article short, we'll:

This means that you have to install the new component:

$ ./composer.phar require "symfony/filesystem:~2.4"

And then you need to fill the execute method:

<?php
// File: src/Gnugat/Fossil/LicenseCommand.php

namespace Gnugat\Fossil;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;

class LicenseCommand extends Command
{
    // configure method...

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $path = $input->getOption('path').'/LICENSE';
        $license = implode(' ', array(
            'Copyright (c)',
            $input->getArgument('author'),
            $input->getArgument('year'),
        ));

        $filesystem = new Filesystem();
        $filesystem->dumpFile($path, $license.PHP_EOL);

        $output->writeln(sprintf('Created the file %s', $path));
    }
}

Now running ./fossil license "Loïc Chardonnet" "2013-2014" -p="/tmp" will output the message "Created the file /tmp/LICENSE", which should be what really happened.

Thin controller, many small services

I'm not a big fan of putting logic in my commands, so generally I use services to do the actual job:

<?php
// File src/Gnugat/Fossil/DocumentationWriter.php

namespace Gnugat\Fossil;

use Symfony\Component\Filesystem\Filesystem;
use Psr\Log\LoggerInterface;

class DocumentationWriter
{
    private $filesystem;
    private $logger;

    public function __construct(Filesystem $filesystem, LoggerInterface $logger)
    {
        $this->filesystem = $filesystem;
        $this->logger = $logger;
    }

    public function write($path, $content)
    {
        $this->filesystem->dumpFile($path, $content);
        $this->logger->notice(sprintf('Created file %s', $path));
    }
}

As you can see, the DocumentationWriter isn't very big. It might seem overkill, but now it's easy to write tests which will check if the LICENSE file has been created. Also, in fossil the class does a bit more work: it checks if the file already exists, and takes a "force overwrite" option into account.

You'll also notice that we inject a logger to notice the user of what happens. We need to install the PSR-3 logger interface:

$ composer require "psr/log:~1.0"

Our command will now be much thinner, just like any controller should be (MVC can also be applied in CLI):

<?php
// File: src/Gnugat/Fossil/LicenseCommand.php

namespace Gnugat\Fossil;

use Gnugat\Fossil\DocumentationWriter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Logger\ConsoleLogger;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;

class LicenseCommand extends Command
{
    // configure method...

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $path = $input->getOption('path').'/LICENSE';
        $license = implode(' ', array(
            'Copyright (c)',
            $input->getArgument('author'),
            $input->getArgument('year'),
        ));

        $filesystem = new Filesystem();
        $logger = new ConsoleLogger($output);
        $documentationWriter = new DocumentationWriter($filesystem, $logger);

        $documentationWriter->write($path, $license.PHP_EOL);
    }
}

To be fair, our command is longer. But it is thinner as it now has less responsibilities:

If you run again ./fossil license "Loïc Chardonnet" "2013-2014" -p="/tmp", you won't see anything: ConsoleLogger hides informative messages by default. You need to pass the verbose option to see the message:

$ ./fossil license -v "Loïc Chardonnet" "2013-2014" -p="/tmp"

Registering the services

The dependency creation isn't a responsibility a controller should have. We'll delegate this to the Dependency Injection component:

$ ./composer.phar require "symfony/dependency-injection:~2.4"

We'll also install the Config component:

$ ./composer.phar require "symfony/config:~2.4"

If you don't know yet this component, go read this helpful article.

We'll create a XML file to configure the registration of our services:

<?xml version="1.0" ?>

<!-- File: config/services.xml -->

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="symfony.application"
            class="Symfony\Component\Console\Application">
            <argument key="name">Fossil</argument>
            <argument key="version">2.0.0</argument>
            <call method="add">
                 <argument type="service" id="fossil.license_command" />
            </call>
        </service>

        <service id="fossil.license_command" class="Gnugat\Fossil\LicenseCommand">
            <argument type="service" id="fossil.documentation_writer" />
        </service>

        <service id="fossil.documentation_writer" class="Gnugat\Fossil\DocumentationWriter">
            <argument type="service" id="symfony.filesystem" />
            <argument type="service" id="symfony.console_logger" />
        </service>

        <service id="symfony.filesystem" class="Symfony\Component\Filesystem\Filesystem">
        </service>

        <service id="symfony.console_logger" class="Symfony\Component\Console\Logger\ConsoleLogger">
            <argument type="service" id="symfony.console_output" />
        </service>

        <service id="symfony.console_output"
            class="Symfony\Component\Console\Output\ConsoleOutput">
        </service>
    </services>
</container>

As you can see, I've delegated every construction to the DIC (Dependency Injection Container), even the construction of the application. Now the command looks like this:

    <?php
// File: src/Gnugat/Fossil/LicenseCommand.php

namespace Gnugat\Fossil;

use Gnugat\Fossil\DocumentationWriter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LicenseCommand extends Command
{
    private $documentationWriter;

    public function __construct(DocumentationWriter $documentationWriter)
    {
        $this->documentationWriter = $documentationWriter;

        parent::__construct();
    }

    protected function configure()
    {
        $this->setName('license');
        $this->setDescription('Bootstraps the license file of your project');

        $this->addArgument('author', InputArgument::REQUIRED);
        $this->addArgument('year', InputArgument::REQUIRED);

        $this->addOption('path', 'p', InputOption::VALUE_REQUIRED, '', getcwd());
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $path = $input->getOption('path').'/LICENSE';
        $license = implode(' ', array(
            'Copyright (c)',
            $input->getArgument('author'),
            $input->getArgument('year'),
        ));

        $this->documentationWriter->write($path, $license.PHP_EOL);
    }
}

And the console now contains the DIC initialization:

#!/usr/bin/env php
<?php
// File: fossil

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;

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

$container = new ContainerBuilder();
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/config'));
$loader->load('services.xml');

$output = $container->get('symfony.console_output');

$application = $container->get('symfony.application');
$application->run(null, $output);

And voilà! You now know how to create CLI applications :) .

Conclusion

The Console component allows you to create CLI applications. The commands are a thin layer which gathers the input and call services. Those services can then output messages to the user using a special kind of logger.

Although this article was a bit long, I might have missed something here, so if you have any feedbacks/questions, be sure to contact me on Twitter.