Work In Progress

This book is currently work in progress. Some sections are not yet written. Thank you for your understanding!

The CLI application

In Joomla 3 we could create CLI scripts for Joomla by extending the JApplicationCli class (later renamed to Joomla\CMS\Application\CliApplication) in a new file which we'd place in Joomla's cli directory. While that was a useful way to get access to all Joomla resources and API from our CLI it created a problem: there were too many files in the cli directory and it was not always clear which component they belong to. It also put a burden on third party developers like us to provide a files package with these CLI scripts and make sure that it gets updated together with our main extension package. There was also a lot of boilerplate code that had to go into each file, potentially different for each Joomla version we supported.

In Joomla 4 we can still do that (but it's deprecated) or we can use the new Joomla CLI Application. This lives in cli/joomla.php. It is an extensible application using a CMSApplication class which uses the Symfony Console — much like Composer, WP-CLI and Drush. Unlike the aforementioned applications, it is not standalone. It is part of your site, making it the fourth official Joomla application bundled with the Joomla CMS (the other three being the frontend a.k.a. site application, the backend a.k.a. administrator application and the API application).

Command classes

Like all Symfony Console applications, the Joomla CLI Application is extended with Command classes. The Joomla command classes need to extend from the \Joomla\Console\Command\AbstractCommand class.

Where do command classes live?

There are no hard rules about where you should put your command classes. There are two possibilities which make the most sense, as far as I can see.

  1. Inside the console plugin which registers the commands to the Joomla CLI Application.

    It makes sense because the plugin contains the code to the commands and registers them to the CLI application. However, it is possible that someone may disable or uninstall your component but not the console plugin. This would mean that the commands, or even their registration, will fail due to the component no longer being bootable. You will need to add special code to address that. Furthermore, there's the small but real risk that the user ends up with a console plugin that's older or newer than the installed component version, potentially causing havoc when the CLI commands are used.

  2. Inside the backend part of your component.

    This is the way I prefer to do things and what I will show you. The commands are in the CliCommand leaf namespace of my component. For example, with a component that has the namespace prefix Acme\Component\Example I would put my commands in the Acme\Component\Example\Administrator\CliCommand namespace i.e. the folder administrator/components/com_example/src/CliCommand.

    The plugin does not need to check if the component is bootable; if it's not, the PSR-4 autoloader for its namespace won't be loaded and the command classes cannot be found. Therefore I just need to check if the command classes exist. Mixing the versions of the plugin and the component is not a problem either; the plugin will load the command classes it knows of and if some of them do not exist (the plugin is from a newer version) we simply skip it over.

A sample command class

<?php
namespace Acme\Component\Example\Administrator\CliCommand;

defined('_JEXEC') or die;

use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryAwareTrait;
use Joomla\CMS\MVC\Model\DatabaseAwareTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class ItemsList extends \Joomla\Console\Command\AbstractCommand
{
  use MVCFactoryAwareTrait;
  use DatabaseAwareTrait;

  /**
   * The default command name
   *
   * @var    string
   */
  protected static $defaultName = 'example:items:list';

  /**
   * @var   SymfonyStyle
   */
  private $ioStyle;

  /**
   * @var   InputInterface
   */
  private $cliInput;

  /**
   * @inheritDoc
   */
  protected function doExecute(InputInterface $input, OutputInterface $output): int
  {
    // Configure the Symfony output helper
    $this->configureSymfonyIO($input, $output);

    // Collect the options
    $search = $input->getOption('search') ?? null;

    // Get the items, using the backend model
    /** @var \Joomla\CMS\MVC\Model\BaseDatabaseModel $itemsModel */
    $itemsModel = $this->getMVCFactory()->createModel('Items', 'Administrator');

    if ($search)
    {
      $itemsModel->setState('filter.search', $search);
    }

    $items = $itemsModel->getItems();

    // If no items are found show a warning and set the exit code to 1.
    if (empty($items))
    {
      $this->ioStyle->warning('No items found matching your criteria');

      return 1;
    }

    // Reshape the items into something humans can read.
    $items = array_map(
      function (object $item): array
      {
        return [
          $item->id,
          $item->title,
          $item->published ? Text::_('JYES') : Text::_('JNO')
        ];
      },
      $items
    );

    // Display the items in a table and set the exit code to 0
    $this->ioStyle->table(
      [
        Text::_('COM_EXAMPLE_FIELD_HEADER_ID'),
        Text::_('JGLOBAL_TITLE'),
        Text::_('JPUBLISHED'),
      ],
      $items
    );

    return 0;
  }

  /**
   * Configure the command.
   *
   * @return  void
   */
  protected function configure(): void
  {
    $this->setDescription(Text::_('COM_EXAMPLE_CLI_ITEMS_LIST_DESC'));
    $this->setHelp(Text::_('COM_EXAMPLE_CLI_ITEMS_LIST_HELP'));

    $this->addOption('search', 's', InputOption::VALUE_OPTIONAL, Text::_('COM_EXAMPLE_CLI_CONFIG_SEARCH'));
  }

  /**
   * Configure the IO.
   *
   * @param   InputInterface   $input   The input to inject into the command.
   * @param   OutputInterface  $output  The output to inject into the command.
   *
   * @return  void
   */
  private function configureSymfonyIO(InputInterface $input, OutputInterface $output)
  {
    $this->cliInput = $input;
    $this->ioStyle  = new SymfonyStyle($input, $output);
  }

}

The command name ($defaultName)

A command class needs to provide a name for the command being executed. In Joomla 4 ,third party extensions should use the convention component:command and component:command:subcommand where component is the name of the component without com_. The command part is the name of your command. If you need to implement subcommands you can add a third, fourth etc part in your command name, all separated with colons. For example we could have the following names:

  • example:email Process an email queue

  • example:items:list Produce a list of items

  • example:items:delete Delete an item

The name of the command goes into the command class' $defaultName static property.

Command configuration

Each command needs a bit of configuration so that Joomla knows how to display it in a list of commands (php joomla.php list), provide help for a command (e.g. php joomla.php example:items:list --help) and parse the arguments and options to the command. This is done in the configure() method.

Calling setDescription sets the short help text which appears next to the command in the list of available commands.

Calling setHelp sets the longer help text which appears in the per-command help page.

The rest of the method body defines the arguments and options to the commands, whether they are required or optional, their default values and their associated help text. This is explained in detail in the Symfony Console documentation article “Console Input (Arguments & Options)”.

Command implementation

The implementation of your command, the code which executes when you call it, is in the doExecute method.

The first thing we do is get a SymfonyStyle object to facilitate the formatting our command's output. It has all sorts of useful things, from titles and success / warning / error blocks to progress bars and tables. You can read more about it in the Symfony Console documentation article How to Style a Console Command.

The rest of the code is pretty straightforward; it's standard Joomla stuff. What you need to note is that we always return an integer. This MUST be an integer from 0 to 255 (inclusive). It is used as the command line application's exit code. It is very strongly recommended that you use a different exit code for each result state of your command and document it. This can be used in automation scenarios, e.g. someone using your CLI commands in an Ansible playbook or a custom shell script.

[Note]Note

You may see that I've used the MVCFactoryAwareTrait and DatabaseAwareTrait traits. We will be using these traits to pass our component's MVCFactory and the Joomla database object when creating objects out of our command classes, before registering them to the Joomla CLI application, in our console plugin.

While we could skip that and just fetch these dependencies directly in our console class, please do keep in mind that Joomla is trying to get us to use dependency injection, i.e. the object should be pushed the dependencies into it rather than having it to pull them from somewhere else. If there's a big architectural change in Joomla it's far easier to change things at the singular injection point rather than hunting down all places where we might be pulling dependencies. That's one of the many reasons Dependency Injection is used: it makes refactoring easier.