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).
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.
-
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.
-
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 prefixAcme\Component\Example
I would put my commands in theAcme\Component\Example\Administrator\CliCommand
namespace i.e. the folderadministrator/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 | |
---|---|
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 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. |