Writing beautiful command line applications using the Console component

I have to write some CLI applications from time to time and in this blog post I want to tell you how I do it. We will talk about the Symfony2's Console component which makes such tasks trivial and even pleasant.

Use cases

When I think about CLI and its use cases, the first things that come into my head are:

- Manual tasks automation

- Web spiders

- Database migrations

- Admin tools

Instrument: the Console component

The Console component eases the creation of beautiful and testable command line interfaces.

Installlation

As always, you can easily install the Console component using Composer:

composer require symfony/console

That's it.

Features and Usage

Let's imagine that we have to write a simple data scraper, that accepts a URL and a path to put results onto.

First, let's check what we have inside this component. The Symfony2 Console component consists of a few main parts:

Application

This is the core of the component. All you need to build a command line application is to create an instance of the \Symfony\Component\Console\Application class, register all your commands there and then run the application. You can do it via:

<?php

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

$app = new \Symfony\Component\Console\Application('4devs - web scraping', 'v1.0');
$app->run();

It accepts two arguments: the name and the version of your application. They are both optional.

Now you can run your app via:

php bin/console.php

By default it will show a list of all available commands. You can also remove a .php extension via:

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

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

// ...

Then it will be possible to run your application via:

bin/console

Command

This is where you write your CLI commands. To create your own command, you just need to extend the Symfony\Component\Console\Command\Command class and implenent a few its methods:

<?php

// src/Command/ExtractPostsCommand.php
namespace FDevs\Command;

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

class ExtractPostsCommand extends Command
{
    public function configure()
    {
        // todo: implement
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     *
     * @return int
     */
    public function execute(InputInterface $input, OutputInterface $output)
    {
        // todo: implement
    }
}

1) Configure

You can configure all CLI aspects of your command here. For example, you can set up the name of your command, description, arguments and options. As we plan to put a URL and output, let's add them as an argument and an option:

<?php

//...

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;

// ...

public function configure()
{
        $this
            ->setName('fdevs:collect-data')
            ->setDescription('Collect blog posts information from a given URL')
            ->addArgument(
                'url',
                InputArgument::REQUIRED,
                'A URL to scrape data from'
            )
            ->addOption(
                'output',
                'o',
                InputOption::VALUE_OPTIONAL,
                'Where to put the results',
                'var/blogPosts.csv'
            )
        ;
}

Now we need to register our command in our application:

$app->add(new \FDevs\Command\ExtractPostsCommand());

Let's check what we have so far:

bin/console

The CLI application output

Here we can see the list of all available commands. You can use -h or --help to get more descriptive usage of any command. For example, I want to check how to use the fdevs:collect-data command in more detail:

bin/console fdevs:collect-data -h

It will show me something like:

The fdevs:collect-data command usage example

You can see that our command now has one argument which is required (url); it also has an optional option called -o or --output that has its own default value. You can use different types and requirements for arguments and options. Please check the InputArgument and InputOption classes for more details (they have a list of constants and you can use them as bitmasks, combining them through |):

<?php

// ...
->addArgument(
    // ...
    InputArgument::REQUIRED | InputArgument::IS_ARRAY,
)

 2) Execute

Ok, we defined our command and it's time to implement its main method - execute:

/**
 * @param InputInterface $input
 * @param OutputInterface $output
 *
 * @return int
 */
public function execute(InputInterface $input, OutputInterface $output)
{
    $url = $input->getArgument('url');
    $outputPath = $input->getOption('output');

    $output->writeln('Starting scraper...');
    $scraper = new BlogPostsScraper();
    $blogPosts = $scraper->getBlogPosts($url);

    $output->writeln(sprintf('Got %d blog posts', count($blogPosts)));

    // write to a file
    // ...
    
    $output->writeln('Done');

    // If everything is fine, we should return 0 to allow pipeline calls
    return 0;
}

This method accepts $input and $output. As you may guess, you can get input arguments and options from the InputInterface interface and write back to a command line through the OutputInterface interface.

In our case we just get the url argument and the output option. Then we create our service to do the main job and get the results. That's it.

Let's run our command:

bin/console fdevs:collect-data http://4devs.io -o blog_posts.csv
Starting scraper...
Got 11 blog posts
Done

Of course, the Symfony2's Console component has a lot of other useful parts in it, like formattters (with colored output) and helpers. I decided to omit them as I omitted BlogPostsScraper, because the main purpose of this blog post is to show the most common way to use this component, not just some rare use cases or custom application logic. You can always dive into the Console component documentation for more details or read about web scraping.

What else you should keep in mind

You should treat your CLI command as another type of controller. Let's think about it a little. Your application shouldn't be command centric; a command should rely on a service instead and just calling its methods when needed. If you follow this mind set, then it will be easy for you to reuse this code later. In this case, by a service I mean that it should be a separated class that can solve one problem, do one special task and nothing more. Let's imagine that you have to write a command to scrape data from a site. You have two choices:

1) write a command and put all business logic inside it

2) move business logic into a service and just call the methods needed inside your command

You may think that a service in a such context is useless, but it makes your application more flexible.

What if your client says tomorrow: "I want you to create a user inferface, so I can also do data scraping when I want, not only by a Cron schedule".

If you use a service, then this new task will be trivial for you. You will just add a controller and call your service there. That's it. If you used the first variant, then you would need to:

- separate business logic into a service (yes, you would need to do it anyway)

- or call your command inside your new controller. To be honest, this seems silly to me. Why do I need to call a console command inside my controller to do web scraping? Should they really be coupled?

Next time, your client may ask you something like: "I have both CLI and a user interface now and it's really great. I also have partners and they're willing to pay me to use my service, so I want you to create an API. Can you do it?"

Again, if your business logic is separated and incupsulated in its own service, then you're lucky again. You can do this with minimum effort. You will just need to create a new endpoint and use your famous service inside it. That's it. So, you will end up with a similar structure in the end:

Service centric approach

The purpose of these examples was to motivate you to not be lazy and create a service for your business logic.

Conclusion

Hope this blog post helped you and you will be able to easily create a beautiful command line application next time.