Building an easily distributable single-file command line app with PHP and Composer

pretzelhands

pretzelhands

Posted on January 21, 2021

Building an easily distributable single-file command line app with PHP and Composer

This post was compiled from two of my Twitter threads. Be sure to follow me to see the new ones first!


In May 2020, Symfony released version 5.1 of their components and included something truly beautiful for us developers: The SingleCommandApplication class. This allows you to define and immediately run a command line app in 32 lines of code. Here it is:

#!/usr/bin/env php

<?php

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

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

const JOKE_API_ENDPOINT = 'https://official-joke-api.appspot.com/jokes/%s/random';

function command(InputInterface $input, OutputInterface $output)
{
    $topic = $input->getOption('topic');
    $jokeResponse = file_get_contents(sprintf(JOKE_API_ENDPOINT, $topic));
    [$joke] = json_decode($jokeResponse);

    $output->writeln($joke->setup);
    $output->writeln("<info>{$joke->punchline}</info>\n");
}

(new SingleCommandApplication())
    ->setName('Joke Fetcher')
    ->addOption(
        'topic', 't',
        InputOption::VALUE_OPTIONAL,
        'Get a joke relating to a specific topic',
        'general'
    )
    ->setCode('command')
    ->run();
Enter fullscreen mode Exit fullscreen mode

And this is what it looks like when you run it.

Command line app fetching two jokes

Let's examine the app piece by piece.

Dependencies

For building the command line app, we require only a single dependency from Symfony.

$ composer require symfony/console
Enter fullscreen mode Exit fullscreen mode

Once that's installed, you're ready to go!

Setting the stage

The first 12 lines are all setup. We include the autoloader and all classes we need. We also setup our endpoint as a constant so it doesn't become a magic string floating around in our application code.

#!/usr/bin/env php

<?php

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

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

const JOKE_API_ENDPOINT = 'https://official-joke-api.appspot.com/jokes/%s/random';
Enter fullscreen mode Exit fullscreen mode

👀 Take note of the shebang (#!/usr/bin/env php) on line 1! It enables us to run our helper using ./joke-fetcher instead of having to call PHP specifically (php joke-fetcher).

Defining the command

Our actual command is just a PHP function called exactly that: command. Making it a separate function is a style choice. You can pass a closure directly to the SingleCommandApplication.

function command(InputInterface $input, OutputInterface $output)
{
    $topic = $input->getOption('topic');
    $jokeResponse = file_get_contents(sprintf(JOKE_API_ENDPOINT, $topic));
    [$joke] = json_decode($jokeResponse);

    $output->writeln($joke->setup);
    $output->writeln("<info>{$joke->punchline}</info>\n");
}
Enter fullscreen mode Exit fullscreen mode

In here we fetch the topic that the user can give us, and request our joke endpoint. We then output it with some pretty formatting.

In a real application, you'd want to use a proper HTTP client to do this. file_get_contents/json_decode is just easy for demonstration.

Also you can also learn more about console output formatting in the Symfony docs

Building our SingleCommandApplication

This is the interesting part! We create a SingleCommandApplication and immediately use the return value to define various settings.

(new SingleCommandApplication())
    ->setName('Joke Fetcher')
    ->addOption(
        'topic', 't',
        InputOption::VALUE_OPTIONAL,
        'Get a joke relating to a specific topic',
        'general'
    )
    ->setCode('command')
    ->run();
Enter fullscreen mode Exit fullscreen mode

setName is optional. It just improves the output of some pre-delivered options (e.g. --version).

addOption allows us to add a command line flag the user can set. It takes the following arguments:

  • The name of the option (--topic)
  • A shortcut for the option (-t)
  • A "mode" (Is the option is required, optional or boolean?)
  • A description of the command (used for --help)
  • A default value (The standard topic of our jokes)
->addOption(
    'topic', 't',
    InputOption::VALUE_OPTIONAL,
    'Get a joke relating to a specific topic',
    'general'
)
Enter fullscreen mode Exit fullscreen mode

If you're using PHP8 already: This is a great place to use named arguments to selectively set options.

However, the optional arguments all have UX implications. It's in your best interest to set sensible values for them.

The second to last line (setCode) sets the code the command line app should run. This can be any kind of PHP callable

💁‍♂️ I like pulling out functions to avoid nesting and improve reusability

The last line (->run()) immediately runs the app we've just made.

And there you have it. You can now run your app and enjoy corny jokes!

If you want, you can also rename your PHP file to remove the .php extension and run it as ./<filename> -- You just have to add the executable flag with chmod!

Making the app distributable

To make our app easily distributable and executable, we'll have to turn it into a PHP archive, better known as a PHAR file.

For this we'll use the ✨ beautiful phar-composer package by clue.engineering

Installing it is a lot like installing composer, if you do it manually. The steps look like this

$ curl -JOL https://clue.engineering/phar-composer-latest.phar`
$ php phar-composer-1.2.0.phar install clue/phar-composer
$ rm phar-composer-1.2.0.phar
Enter fullscreen mode Exit fullscreen mode

❗️ You should always double-check what you're pulling into your shell with curl/wget, but I vouch for this one

Also note, that the version may not be 1.2.0 for you. The package is regularly updated.

Preparing our composer.json

Now we need to add a bin property to our composer.json to let phar-composer know which file is runnable. In my case it's cli.php and it's in the app root folder

{
    "name": "pretzelhands/joke-fetcher",
    "require": {
        "symfony/console": "^5.2"
    },
    "bin": ["cli.php"]
}
Enter fullscreen mode Exit fullscreen mode

As a ✨quality-of-life improvement✨ I also like to add a Composer script called phar that just calls the necessary command for us.

So instead of running

$ phar-composer build
Enter fullscreen mode Exit fullscreen mode

I can run

$ composer run phar
Enter fullscreen mode Exit fullscreen mode

which is more consistent within most of my projects. To add the Composer script, edit your composer.json file and add a new scripts property like this:

{
    "name": "pretzelhands/joke-fetcher",
    "require": {
        "symfony/console": "^5.2"
    },
    "scripts": {
        "phar": "phar-composer build"
    },
    "bin": ["cli.php"]
}
Enter fullscreen mode Exit fullscreen mode

Now you can run it! In my case the joke-fetcher app we wrote above comes out to ~980KB.

💄 For aesthetic reasons you can also rename your PHAR to remove the file extension.

🔥 You can share this app with anyone who has PHP installed and they can just run it with chmod 755!~

Final notes

  • 🤬 phar-composer will complain if phar.readonly is set to off in your php.ini - This doesn't stop it from working. But it advises you how to get rid of the message

  • 💬 For the name of your PHAR file, phar-composer looks at the name prop in your composer.json

  • 🚮 phar-composer won't check if there's already a PHAR in the project directory. It'll just package it in there, making your PHAR twice as big

You need to delete it yourself. You can make this part of the composer run phar script. This is left as an exercise for the reader ;)

Further reading

Look at these links if you want to learn more about the Symfony Console command, phar-composer and various details of PHP!

And here's a gist, for anyone who wants to see the entire code at once!


If you enjoyed this post and want to read more like this, follow me on Twitter! Or subscribe to my newsletter

💖 💪 🙅 🚩
pretzelhands
pretzelhands

Posted on January 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related