Sunday, May 5, 2013

A basic skeleton for Symfony Console applications


Symfony provides a great infrastructure for writing console-oriented applications. But, as the lazy person I must confess I am, it feels like a great deal starting from scratch with a of a lot of code just for a "tiny" utility.
Well, as laziness is a great motivation to invest in potential time-saving actions for the future, I wrote a working skeleton for a Symfony CLI application. The thing can be downloaded from GitHub (just pick the last version tag) or clone the whole repository as usual.

You can run but you can't hide

The application entry point is run.php file with this contents:
    <?php
    // run.php
    /*
     * Run the application
     */
    namespace YourNamespace;
    
    use Skel\DependencyInjection\Application;
    
    // the autoloader
    $loader = require __DIR__ . '/vendor/autoload.php';
    
    // create application
    $application = new Application(__NAMESPACE__);
    
    // and run
    $application->run();
(the YourNamespace namespace is intended to be replaced with your own across the application).
Nothing to customize here. Just run it with php run.php and it will work.

It Depends

As you can see in the above example, the skeleton uses a Dependency Injection Container but, surprisingly as it may be for some, is not Pimple. Why? Because I wanted to keep things simple but not too simple. Pimple is great and easy to use, but Symfony Dependency Injection component offers, among a lot of other goodies, the ability to be configured via a configuration file (i.e. services.yml).
So we have a config/services.yml file with the following contents:
    # config/services.yml
    services:
      filesystem:
        class: Symfony\Component\Filesystem\Filesystem
        
      version:
        class: Skel\Lib\Version
You can (and should) add any new dependency there as usual.

Application class

This class is pretty straightforward for the most part but has two remarkable pieces:
    <?php
    // src/Skel/DependencyInjection/Application.php
    /**
     * @param string $baseNamespaceName Highest level namespace for the application
     */
    public function __construct($baseNamespaceName)
    {
        // our error handler
        ErrorHandler::register();

        // create and populate the container
        $this->container = new ContainerBuilder();

        // some useful paths
        $paths = array();
        $paths['root'] = __DIR__ . '/../../../';
        $paths['config'] = $paths['root'] . 'app/config/';
        $paths['build'] = $paths['root'] . 'build/';
        $this->container->setParameter('paths', $paths);

        // the main config
        $loader = new YamlFileLoader($this->container, new FileLocator($paths['config']));
        $loader->load('config.yml');

        // construct the application
        $app = $this->container->getParameter('application');
        $version = $this->container->getParameter('version');
        parent::__construct($app['name'],$version['current']);

        // and add commands to it
        $this->addConsoleCommands($baseNamespaceName);

        // may be we have some commands also
        $this->addConsoleCommands(__NAMESPACE__);
    }
The configuration is read with:
    <?php
    // src/Skel/DependencyInjection/Application.php
    // ...
    $loader = new YamlFileLoader($this->container, new FileLocator($paths['config']));
    $loader->load('config.yml');
    // ...
And config.yml has the following contents:
    # config/config.yml 
    imports:
    - { resource: build.yml }
    - { resource: services.yml }

    parameters:
      application: 
        name:         Symfony CLI skeleton
        slug:         symcliskel    # Used to name the built phar
        description:  "Basic skeleton for building Symfony Command Line Interface (CLI) applications"
First of all it imports files 'build.yml' (more on that later) and 'services.yml' that was covered before; then it defines a few useful parameters to be used in the application.
In 'Application.php' we also have method 'addConsoleCommands()' that does the magic for autoregistering the commands it finds in 'src/YourNamespace/Console/Command'.
    <?php
    // src/Skel/DependencyInjection/Application.php
    /**
     * Adds all existing console commands
     *
     * @param string $baseNamespaceName Base namespace for the commands
     */
    protected function addConsoleCommands($baseNamespaceName)
    {
        // get all namespaces from the composer autoload list
        $paths = $this->container->getParameter('paths');
        $namespaces = include $paths['root'] . '/vendor/composer/autoload_namespaces.php';

        // find the path for the namespace
        foreach ($namespaces as $namespace => $lookupPaths) {
            if ($namespace == $baseNamespaceName) {
                // add all existing commands
                foreach ($lookupPaths as $path) {
                    $commandPath = $path . '/' . $namespace . '/Console/Command/';
                    if (is_dir($commandPath)) {
                        $files = Finder::create()->files()->name('*Command.php')->in($path . '/' . $namespace . '/Console/Command/');
                        foreach ($files as $file) {
                            $className = $file->getBasename('.php'); // strip .php extension
                            // ignore Base*Command classes (base for commands)
                            if (strpos($className, 'Base') === false) {
                                $r = new \ReflectionClass($baseNamespaceName . '\Console\Command' . '\\' . $className);
                                $this->add($r->newInstance());
                            }
                        }
                    }
                }
            break;
            }
        }
    }

Build it

Once you have your CLI application up and running it should be distributed to is users. The equivalent of a compiled application in PHP-land is a PHAR (PHP archive).
As useful as PHAR's are, its creation is not so straigthforward as one could wish. No problem: the skeleton comes with its own PHAR builder:
    <?php
    // build.php
    /*
     * Build the application
     */
    namespace YourNamespace;

    use Skel\DependencyInjection\Application;
    use Skel\Lib\Version;
    use Symfony\Component\Yaml\Yaml;

    // the autoloader
    $loader = require __DIR__.'/vendor/autoload.php';

    // create application and get the container
    $app = new Application(__NAMESPACE__);
    $container = $app->getContainer();

    // get the version manager
    $vm = new Version('app/config/build.yml');

    // start
    $pathsParam = $container->getParameter('paths');
    $appParam = $container->getParameter('application');

    $buildPath = $pathsParam['build'];
    $buildTarget = $appParam['slug'].'.phar';
    $output = $buildPath.$buildTarget;
    echo sprintf('Building "%s" version "%s" into "%s"'."\n", $buildTarget, $vm->getVersion(), realpath($buildPath));

    // remove old
    @unlink($output);

    // start phar creation
    $phar = new \Phar($output);
    $phar->startBuffering();

    // the runner
    $phar->addFile('run.php');

    // add other paths if needed
    $phar->buildFromDirectory(__DIR__, '/\/app\//');
    $phar->buildFromDirectory(__DIR__, '/\/src\//');
    $phar->buildFromDirectory(__DIR__, '/\/vendor\//');

    // this will make the phar autoexecutable
    $defaultStub = $phar->createDefaultStub('run.php');
    $stub = "#!/usr/bin/env php \n" . $defaultStub;
    $phar->setStub($stub);

    $phar->compressFiles(\Phar::GZ);

    $phar->stopBuffering();

    // set execution rights
    chmod($output, 0755);

    // increment version

    $vm->incrementVersion();

    echo "Finished!!\n";
It packages the app, src and vendor together with run.php to produce an standalone .phar file that can be renamed or copied anywhere.

Versions

build.php updates the application version after the build, so the version that is shown when you run php run.php and when you build the application with php build.php are the same.

Get it

All code is available in GitHub:
https://github.com/magabriel/symfony-cli-skeleton
As usual, follow the instructions in the README document (do not forget to install vendors using composer install or composer update).

Update

2013-07-07: Updated to match the new way Composer uses lookup paths.

No comments:

Post a Comment