Building an easily distributable single-file command line app with PHP and Composer
pretzelhands
Posted on January 21, 2021
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();
And this is what it looks like when you run it.
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
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';
👀 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");
}
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();
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'
)
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
❗️ 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"]
}
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
I can run
$ composer run phar
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"]
}
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 ifphar.readonly
is set tooff
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 thename
prop in yourcomposer.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!
- 📦
phar-composer
: https://github.com/clue/phar-composer - 👀 PHAR files: https://php.net/manual/en/intro.phar.php
-
SingleCommandApplication
: https://symfony.com/doc/current/components/console/single_command_tool.html - Symfony Console component: https://symfony.com/doc/current/components/console.html
- Console options and arguments: https://symfony.com/doc/current/components/console/console_arguments.html
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
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
January 21, 2021