Console Command Plugins in Joomla
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
A console plugin is a Joomla plugin of the group console whose only job is to hand new commands to the Joomla
command-line application, so that php cli/joomla.php list shows them next to cache:clean and scheduler:run. This
page was written against Joomla 6.2.x on PHP 8.3 or newer (the Joomla 6 minimum), where the console is built on the
Joomla Framework joomla/console package 4.0 and Symfony Console 7, and it was checked against the Joomla Developer
Manual and the 6.2 source tree on 2026-10-30. The running example is the Tallpine Hiking Club site: the plugin
plg_console_tallpine, a part of the package pkg_tallpine, adds a tallpine:import command that reads GPX files from
a folder and creates trails in the com_tallpine component.
How the command line itself behaves (bootstrap, global options, every core command, cron, Docker and CI) is described in
Joomla command-line interface; this page is about writing the commands.
Plugin basics (the SubscriberInterface, the event classes, the group names) are in
Plugin development and events, the namespaced layout and the
service provider in Namespaces, autoloading and
dependency injection, and the manifest and packaging in
Extension types, manifests and packaging. PHP itself is not
re-taught here: see Command-line PHP for the CLI SAPI, streams and
exit codes.
What a console plugin is, and who uses it
Joomla has no directory that it scans for command classes. Core commands are registered lazily by a service provider
inside the CMS library. Every other command, from Joomla itself or from a third party, is contributed by a plugin of the
console group: when the command-line application starts, it imports the enabled plugins of the groups behaviour,
system and console, and a console plugin answers by calling addCommand() with a command object.
-
Extension developers ship administrative commands with their extension: an importer, a re-index, a report, a repair routine.
-
Site builders and agencies write a one-off migration or a nightly job for one site and keep it as a small private plugin.
-
Administrators and DevOps engineers only see the result: new lines in
list, new--helppages, and exit codes thatcronand CI understand. They need the plugin installed and enabled.
The alternative for recurring work is a scheduled task plugin (see
Scheduled task plugins). A task has a timetable stored in the database, a
log, an administrator screen and a lock, and runs inside the same machinery as scheduler:run. A console command has
none of that: it is a plain program with arguments, which makes it better for work that a person or a deployment
pipeline starts on purpose, that needs input (a folder, a file, a dry-run flag) or that must report an exit code to a
script. The Tallpine site uses both: plg_task_tallpine_trailsync runs on a timetable, and tallpine:import is run by a
volunteer after a hike or by the deployment job.
How the command line finds your command
The steps that matter to a plugin author:
-
ConsoleApplication::execute()(classJoomla\CMS\Application\ConsoleApplication) loads the extension namespace map, populates the HTTP host, and then callsPluginHelper::importPlugin()for the three groups on its own dispatcher. For each enabled plugin of those groups Joomla loadsservices/provider.php, gets the plugin from the container and registers it as an event subscriber. A plugin that is installed but disabled is never loaded, and a plugin of any other group (content,user,task) is not loaded at all. -
The Framework application dispatches the event
ApplicationEvents::BEFORE_EXECUTE(application.before_execute) before it resolves the command name from the command line. A console plugin subscribes to it and, in its handler, callsaddCommand()for each command. If the handler ran later, the requested command would already have been looked up and reported as undefined. -
addCommand(AbstractCommand $command)initialises the Framework command store, skips a command whoseisEnabled()returns false, gives the command a reference to the application, requires that it has a name and stores it under that name and each alias. A command that has no name throws aLogicException. -
The application then matches the typed name against the store (with unambiguous abbreviations such as
tallpine:impand with the namespace prefix, as Symfony does), dispatchesconsole.before_command_execute, runs the command and dispatchesconsole.terminate.
Two consequences are worth stating early. First, a command added with an existing name replaces the earlier one: the
store is keyed by name, so a plugin can, deliberately or by accident, override a core command such as cache:clean.
Choose a vendor prefix (tallpine:) for every command name. Second, because the plugin is built on every CLI start,
including list and help, its constructor and registerCommands() must be cheap: no network calls, no database
queries, no file scans.
The package: files and manifest
The plugin follows the same layout as every Joomla 5 and 6 plugin. It has no plg_console_tallpine.php entry file; the
service provider is the entry point.
plg_console_tallpine/
tallpine.xml
services/
provider.php
src/
Extension/
Tallpine.php
Command/
ImportTrailsCommand.php
TrailStatsCommand.php
Gpx/
GpxReader.php
language/
en-GB/
plg_console_tallpine.ini
plg_console_tallpine.sys.ini
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="console" method="upgrade">
<name>plg_console_tallpine</name>
<author>Tallpine Hiking Club</author>
<creationDate>2026-10</creationDate>
<copyright>(C) 2026 Tallpine Hiking Club</copyright>
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
<version>1.1.0</version>
<description>PLG_CONSOLE_TALLPINE_XML_DESCRIPTION</description>
<namespace path="src">Tallpine\Plugin\Console\Tallpine</namespace>
<files>
<folder plugin="tallpine">services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_console_tallpine.ini</language>
<language tag="en-GB">language/en-GB/plg_console_tallpine.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="default_category"
type="category"
extension="com_tallpine"
label="PLG_CONSOLE_TALLPINE_FIELD_CATEGORY_LABEL"
default=""
filter="integer"
/>
<field
name="max_file_kb"
type="number"
label="PLG_CONSOLE_TALLPINE_FIELD_MAXFILE_LABEL"
default="2048"
min="64"
max="20480"
filter="integer"
/>
</fieldset>
</fields>
</config>
</extension>
The points that differ from other plugin groups:
-
group="console"is what makes the application import the plugin at all. A typo in the group (consol) installs cleanly and the commands never appear. -
The element is
tallpine(the value ofplugin=on theservicesfolder). Another vendor’s console plugin may also use a short element, so a distinctive one avoids a clash within the group. -
The
<config>form is optional. A console plugin has the usual plugin parameters screen under System → Manage → Plugins, and the values reach the plugin class as$this->params. They are site-wide settings: a command-line run has no menu item, no logged-in user and no template to override them. -
The
.sys.inilanguage file carries the name and description shown in the plugin manager and the installer; the.inifile carries the labels of the parameters. A command’s own output text is not translated through them by default (see Output with SymfonyStyle).
The service provider
services/provider.php builds the plugin and injects what it needs. The Hello World example in the Developer Manual
passes the dispatcher to the plugin constructor; that signature is the one of earlier Joomla 4 and 5 code. In Joomla 6
the CMSPlugin constructor accepts the plugin configuration array only, and passing a dispatcher still works but raises
a deprecation notice and stops being supported in 7.0. Core plugins of 6.2 are built as follows.
<?php
/**
* @package Tallpine.Plugin
* @subpackage Console.Tallpine
*
* @copyright (C) 2026 Tallpine Hiking Club
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Tallpine\Plugin\Console\Tallpine\Extension\Tallpine;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new Tallpine(
(array) PluginHelper::getPlugin('console', 'tallpine')
);
$plugin->setApplication(Factory::getApplication());
$plugin->setDatabase($container->get(DatabaseInterface::class));
return $plugin;
}
);
}
};
Source: Joomla Manual: How plugins work
-
PluginHelper::getPlugin('console', 'tallpine')returns the plugin’s database record, including theparamsstring thatCMSPluginturns into$this->params. -
Factory::getApplication()returns the running application. On the command line that is theConsoleApplication, not a web application. This is the one place where theFactoryis still used: it is how every core plugin hands the application to the plugin. -
setDatabase()comes fromDatabaseAwareTrait. Declaring the trait on the plugin and injecting the connection here replaces the pre-5 habit of a$dbproperty thatCMSPluginfilled in with the removedFactory::getDbo(). TheCMSPluginconstructor still detects a property called$appor$dband fills it, with a deprecation notice, and that auto-detection is removed in Joomla 7.0. -
Core providers wrap the construction in
$container->lazy(...)so the plugin object is only created when an event needs it. A console plugin is needed on every CLI start, so the wrapper gains nothing here.
The plugin class
<?php
namespace Tallpine\Plugin\Console\Tallpine\Extension;
use Joomla\Application\ApplicationEvents;
use Joomla\CMS\Application\ConsoleApplication;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Console\Event\BeforeCommandExecuteEvent;
use Joomla\Console\ConsoleEvents;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Event\SubscriberInterface;
use Tallpine\Plugin\Console\Tallpine\Command\ImportTrailsCommand;
use Tallpine\Plugin\Console\Tallpine\Command\TrailStatsCommand;
\defined('_JEXEC') or die;
final class Tallpine extends CMSPlugin implements SubscriberInterface, DatabaseAwareInterface
{
use DatabaseAwareTrait;
public static function getSubscribedEvents(): array
{
return [
ApplicationEvents::BEFORE_EXECUTE => 'registerCommands',
ConsoleEvents::BEFORE_COMMAND_EXECUTE => 'guardImport',
];
}
public function registerCommands(): void
{
$app = $this->getApplication();
if (!$app instanceof ConsoleApplication) {
return;
}
$app->addCommand(
new ImportTrailsCommand(
(int) $this->params->get('default_category', 0),
(int) $this->params->get('max_file_kb', 2048) * 1024
)
);
$app->addCommand(new TrailStatsCommand($this->getDatabase()));
}
public function guardImport(BeforeCommandExecuteEvent $event): void
{
$command = $event->getCommand();
if ($command === null || $command->getName() !== 'tallpine:import') {
return;
}
if (getenv('TALLPINE_ENV') === 'production' && getenv('TALLPINE_ALLOW_IMPORT') !== '1') {
$event->disableCommand();
}
}
}
Notes on the class:
-
It
extends CMSPlugin implements SubscriberInterface.getSubscribedEvents()maps a Framework event name to a public method, which is how every Joomla 5 and 6 plugin subscribes. The legacy style (a method namedonSomethingthatCMSPluginregisters by reflection) works for Joomla events but there is no Joomla event named for the console lifecycle, so the subscriber form is required here. -
The events come from the Joomla Framework, not from the CMS event list:
ApplicationEvents::BEFORE_EXECUTEisapplication.before_execute, andConsoleEvents::BEFORE_COMMAND_EXECUTEisconsole.before_command_execute. The Developer Manual’s list of plugin events documents the CMS groups only and has no entry for them; the Framework classes inlibraries/vendor/joomla/consoleandjoomla/applicationare the reference. -
ConsoleApplicationis the type check that makesaddCommand()safe. The same plugin class could in principle be imported by another application, andgetApplication()is typed as the general CMS application interface. -
The handlers take the event object or nothing. A handler for
application.before_executeneeds no argument. A handler forconsole.before_command_executereceives aBeforeCommandExecuteEventwithgetCommand()and the pairdisableCommand()andenableCommand(). A disabled command is not run and the application returns the exit code113(BeforeCommandExecuteEvent::RETURN_CODE_DISABLED). The guard above makes a production site refusetallpine:importunless an environment variable allows it. -
The class is
final, as core plugin classes are. Nothing needs to extend it. -
Joomla 6 plugin classes obtain the application with
getApplication()and the database withgetDatabase(). The class contains no$this->appand noFactory::getDbo().
The Framework application also dispatches console.command_error (a CommandErrorEvent with getError(),
setError(), getExitCode() and setExitCode()), console.terminate (a TerminateEvent with getExitCode() and
setExitCode()) and console.application_error. A listener on console.command_error is the right place to write
failures to a log or to send a mail; a listener on console.terminate can turn an exit code into another one.
AbstractCommand
Every command extends Joomla\Console\Command\AbstractCommand. It is not the Symfony Command class. It has the same
vocabulary (name, description, help, arguments, options, aliases) but a smaller surface, and it adds a few Joomla-specific
details.
| Member | Purpose |
|---|---|
|
The command name, for example |
|
Called from the constructor. Set the description and help, add arguments and options. Do not read the database or the application here: no application is attached yet. |
|
Optional hook run after the input is bound and before |
|
Abstract. The work of the command. Return an exit code. |
|
Final plumbing: merges the application’s global options, binds and validates the input, calls |
|
The |
|
Metadata. In the help text, |
|
Return false (override it) to keep the command out of the application, for example when a PHP extension is missing. |
The important difference from the Symfony class is the exit codes. AbstractCommand defines no SUCCESS or FAILURE
constants, so self::SUCCESS does not exist. Core commands import the Symfony Command class and use its constants,
and so should plugins.
use Symfony\Component\Console\Command\Command;
return Command::SUCCESS; // 0
return Command::FAILURE; // 1
return Command::INVALID; // 2, for bad arguments or options
Source: Joomla source: SessionGcCommand
A command that does not return an integer fails with a TypeError because doExecute() is declared : int. An
uncaught exception in doExecute() is rendered by the application and ends with a non-zero code, which is usually what a
script wants, but a message written for the operator ($io->error()) followed by Command::FAILURE is friendlier.
Dependencies of a command
A command is an ordinary object created in registerCommands(), so it receives its dependencies through the
constructor. This is how core builds FinderIndexCommand and ExtensionsListCommand (the database passed in from the
console service provider), and it is how TrailStatsCommand below receives its connection. Two rules follow from the way
AbstractCommand is built:
-
A constructor that adds parameters must call
parent::__construct(). The parent constructor sets the name from$defaultNameand callsconfigure(), so state thatconfigure()needs must be assigned before the parent call. -
Core commands that need the container implement
ContainerAwareInterfaceand useContainerAwareTrait, and the console service provider injects it. A third-party plugin has no such hook, so inject the individual services (the database, parameters) instead of the container, or fetch what is needed on demand from the application withbootComponent().
Input: arguments and options
The command line follows the Symfony Console grammar: arguments are positional, options start with -- (long) or -
(shortcut). Declare them in configure(); read them in doExecute().
| Declaration | Behaviour |
|---|---|
|
Positional, must be present. Missing it prints an error and the usage line. |
|
Positional, may be left out; falls back to the default. |
|
Collects the remaining positional values; must be last. |
|
A flag; |
|
|
|
The value may be omitted ( |
|
Repeatable: |
The Joomla application adds its own options to every command: --live-site, plus the Symfony -h, -q, -v, -n,
--ansi and -V described in Joomla command-line interface. Do not declare
an option or shortcut with one of those names (the shortcut -v is verbosity, -q is quiet, -n is no-interaction and
-h is help); the clash is reported when the definitions are merged.
Validation that the Symfony definition cannot express (a folder must exist, a number must be in a range) belongs at the
top of doExecute(), before any work. Return Command::INVALID for bad usage and Command::FAILURE for a runtime
failure, so a script can tell them apart. Treat every value as untrusted text, whoever types it (see
Security of console commands).
Output with SymfonyStyle
doExecute() receives an OutputInterface. Wrap it in Symfony\Component\Console\Style\SymfonyStyle to get the
formatted helpers that core commands use. Joomla does not provide an output API of its own, and the Joomla 3 $this->out()
and $this->in() methods of the removed CliApplication have no direct counterpart.
| Method | Use |
|---|---|
|
Headings. Use |
|
Plain lines and bullet lists. |
|
Coloured, boxed result messages. |
|
Aligned tables of rows, for reports. |
|
A progress bar. It is drawn only on a terminal; the plain output of a redirected run has no bar. |
|
Interactive questions. With |
|
A style bound to standard error, so diagnostics do not pollute the output of a pipe. |
|
Test the |
Source: Joomla source: SessionGcCommand
Good output habits for a command that cron runs:
-
Print nothing on success unless asked. With
-qthe style prints nothing. Without it, keep a singlesuccess()line and useisVerbose()for per-item detail. -
Errors to standard error, results to standard output. A command whose standard output is parsed by another program (
--format=json) writes diagnostics with$io->getErrorStyle(). -
The exit code is the contract. Operators and CI test
$?, so a partial failure (some files imported, some rejected) returnsCommand::FAILUREeven though the run printed a summary. -
Translation. Core commands print English. A command that wants translated output can call
Text::_()after the language is loaded, but the console application does not load a site language for a command, and operators of a multilingual site generally expect log-friendly English; most plugins print English and keep the.inifiles for the plugin manager.
The tallpine:import command
The command reads GPX files from a folder, calculates the length of each track, derives a difficulty and creates one
trail per file in com_tallpine. It supports a dry run, an update mode and a cap on the number of files, and it never
trusts the contents of a file.
The command does not write SQL. It asks the component for its Trail table class and calls bind(), check() and
store(), so the same rules as the administrator edit form apply (alias generation, field defaults, the asset record
for the ACL, the content-change events the Table class dispatches). Direct SQL would skip them: the trail would have no
asset, would not be visible to the permission checks of ACL in extensions and
would be missing from the Smart Search index.
<?php
namespace Tallpine\Plugin\Console\Tallpine\Command;
use Joomla\CMS\Application\ApplicationHelper;
use Joomla\CMS\Application\ConsoleApplication;
use Joomla\CMS\Date\Date;
use Joomla\CMS\MVC\Factory\MVCFactoryServiceInterface;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Tallpine\Plugin\Console\Tallpine\Gpx\GpxReader;
\defined('_JEXEC') or die;
final class ImportTrailsCommand extends AbstractCommand
{
protected static $defaultName = 'tallpine:import';
public function __construct(
private readonly int $defaultCategory = 0,
private readonly int $maxFileBytes = 2097152
) {
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Import trails from a folder of GPX files');
$this->setHelp(
"<info>%command.name%</info> creates one trail per GPX file.\n\n"
. "Usage: <info>php %command.full_name% --folder=/srv/gpx</info>\n"
. "Check first with <info>--dry-run</info>. Existing trails are only changed with <info>--update</info>."
);
$this->addOption('folder', 'f', InputOption::VALUE_REQUIRED, 'Folder that contains the .gpx files');
$this->addOption('category', 'c', InputOption::VALUE_REQUIRED, 'Category id for new trails', null);
$this->addOption('state', null, InputOption::VALUE_REQUIRED, 'State of new trails: 0 unpublished, 1 published', '0');
$this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Import at most this many files', '200');
$this->addOption('update', null, InputOption::VALUE_NONE, 'Update trails whose alias already exists');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and report, write nothing');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$app = $this->getApplication();
$folder = (string) $input->getOption('folder');
$limit = (int) $input->getOption('limit');
$state = (int) $input->getOption('state');
$catid = (int) ($input->getOption('category') ?? $this->defaultCategory);
$dryRun = (bool) $input->getOption('dry-run');
$update = (bool) $input->getOption('update');
// 1. Validate usage before touching anything.
if ($folder === '' || !is_dir($folder) || !is_readable($folder)) {
$io->error('Pass a readable folder with --folder.');
return Command::INVALID;
}
if (!\in_array($state, [0, 1], true) || $limit < 1 || $catid < 1) {
$io->error('Check --state (0 or 1), --limit (1 or more) and --category (a category id).');
return Command::INVALID;
}
if (!$app instanceof ConsoleApplication) {
$io->error('This command needs the Joomla console application.');
return Command::FAILURE;
}
// 2. Ask the component for its table class.
$component = $app->bootComponent('com_tallpine');
if (!$component instanceof MVCFactoryServiceInterface) {
$io->error('com_tallpine is not installed or is not an MVC component.');
return Command::FAILURE;
}
$factory = $component->getMVCFactory();
$files = glob(rtrim($folder, '/') . '/*.gpx') ?: [];
sort($files);
$files = \array_slice($files, 0, $limit);
if ($files === []) {
$io->warning('No .gpx files found.');
return Command::SUCCESS;
}
if ($update && !$dryRun && $input->isInteractive()
&& !$io->confirm('Existing trails with the same alias will be overwritten. Continue?', false)) {
$io->note('Nothing was changed.');
return Command::SUCCESS;
}
// 3. Work file by file; one bad file must not stop the others.
$reader = new GpxReader($this->maxFileBytes);
$rows = [];
$failed = 0;
$io->progressStart(\count($files));
foreach ($files as $path) {
$name = basename($path);
try {
$trail = $reader->read($path);
$alias = ApplicationHelper::stringURLSafe($trail['title']);
$table = $factory->createTable('Trail', 'Administrator');
$exists = $table->load(['alias' => $alias, 'catid' => $catid]);
if ($exists && !$update) {
$rows[] = [$name, $alias, 'skipped (exists)'];
$io->progressAdvance();
continue;
}
if (!$exists) {
$table->reset();
}
$table->bind([
'title' => $trail['title'],
'alias' => $alias,
'catid' => $catid,
'distance_km' => $trail['distance_km'],
'difficulty' => $trail['difficulty'],
'description' => $trail['description'],
'state' => $state,
'access' => 1,
]);
if (!$exists) {
$table->created = (new Date())->toSql();
}
if (!$dryRun) {
$table->check();
$table->store();
}
$rows[] = [$name, $alias, ($exists ? 'updated' : 'created') . ($dryRun ? ' (dry run)' : '')];
} catch (\Throwable $e) {
$failed++;
$rows[] = [$name, '-', 'FAILED: ' . $e->getMessage()];
}
$io->progressAdvance();
}
$io->progressFinish();
// 4. Report, then choose the exit code.
if (!$io->isQuiet()) {
$io->table(['File', 'Alias', 'Result'], $rows);
}
if ($failed > 0) {
$io->error(\sprintf('%d of %d files failed.', $failed, \count($files)));
return Command::FAILURE;
}
$io->success(\sprintf('%d files processed%s.', \count($files), $dryRun ? ' (dry run)' : ''));
return Command::SUCCESS;
}
}
What the example demonstrates:
-
Constructor and
configure()order. The two promoted constructor parameters are assigned beforeparent::__construct()runsconfigure(), so they are available ifconfigure()reads them. They come from the plugin’s parameters inregisterCommands(). -
Validation first, work second. Bad usage returns
Command::INVALID(2) before any file is read. A missing component is a runtime failure (1). -
Booting the component.
bootComponent('com_tallpine')loads the component’s service provider and returns the component object; one that implementsMVCFactoryServiceInterfaceexposesgetMVCFactory(). The factory then creates the table with the component’s namespace (Tallpine\Component\Tallpine\Administrator\Table\TrailTable). This is the same route a controller takes, and it needs the component to be installed and enabled. -
Per-item error handling. A
try/catch (\Throwable)per file collects the failure in the report and continues. In Joomla 6 theTablemethods signal failures with exceptions (thesetError()convention is on its way out: core stops calling it in 7.0), so do not test a boolean return and readgetError(). -
A dry run goes as far as
bind()and stops beforecheck()andstore(). A more thorough dry run also callscheck(), which validates but does not write. -
Interactive confirmation is skipped when there is no terminal.
isInteractive()is false undercron, in a pipe and with--no-interaction, so an unattended--updaterun proceeds without asking. A destructive flag that is safe only with a prompt should additionally require an explicit--force.
The GPX reader is a small class on its own, so that it can be tested without Joomla.
<?php
namespace Tallpine\Plugin\Console\Tallpine\Gpx;
\defined('_JEXEC') or die;
final class GpxReader
{
private const NS = 'http://www.topografix.com/GPX/1/1';
public function __construct(private readonly int $maxBytes)
{
}
/**
* @return array{title: string, distance_km: float, difficulty: string, description: string}
*/
public function read(string $path): array
{
$size = filesize($path);
if ($size === false || $size < 1 || $size > $this->maxBytes) {
throw new \RuntimeException('File is empty or larger than the configured limit');
}
$contents = file_get_contents($path);
// A GPX file has no reason to declare entities. Refuse them instead of parsing them.
if ($contents === false || stripos($contents, '<!DOCTYPE') !== false || stripos($contents, '<!ENTITY') !== false) {
throw new \RuntimeException('File is unreadable or contains a DOCTYPE');
}
$previous = libxml_use_internal_errors(true);
try {
$xml = new \SimpleXMLElement($contents, LIBXML_NONET | LIBXML_NOCDATA);
} catch (\Exception $e) {
throw new \RuntimeException('Not valid XML');
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$xml->registerXPathNamespace('g', self::NS);
$names = $xml->xpath('//g:trk/g:name') ?: $xml->xpath('//g:metadata/g:name') ?: [];
$title = trim((string) ($names[0] ?? ''));
if ($title === '') {
$title = pathinfo($path, PATHINFO_FILENAME);
}
$points = [];
foreach ($xml->xpath('//g:trk//g:trkpt') ?: [] as $point) {
$points[] = [(float) $point['lat'], (float) $point['lon']];
}
if (\count($points) < 2) {
throw new \RuntimeException('Fewer than two track points');
}
$km = round($this->length($points), 1);
return [
'title' => mb_substr($title, 0, 255),
'distance_km' => $km,
'difficulty' => $km < 8 ? 'easy' : ($km < 16 ? 'moderate' : 'hard'),
'description' => '',
];
}
/** Haversine length of the polyline, in kilometres. */
private function length(array $points): float
{
$total = 0.0;
for ($i = 1, $n = \count($points); $i < $n; $i++) {
[$lat1, $lon1] = $points[$i - 1];
[$lat2, $lon2] = $points[$i];
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
$total += 6371.0 * 2 * asin(min(1.0, sqrt($a)));
}
return $total;
}
}
Source: PHP manual: SimpleXML
A second command shows the other common dependency, a database connection passed in by the plugin. It prints a report and has no side effects, so it is also a useful smoke test for the whole plugin.
<?php
namespace Tallpine\Plugin\Console\Tallpine\Command;
use Joomla\Console\Command\AbstractCommand;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
\defined('_JEXEC') or die;
final class TrailStatsCommand extends AbstractCommand
{
protected static $defaultName = 'tallpine:stats';
public function __construct(private readonly DatabaseInterface $db)
{
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Count trails by difficulty');
$this->addOption('state', null, InputOption::VALUE_REQUIRED, 'Only trails in this state', '1');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$state = (int) $input->getOption('state');
$query = $this->db->createQuery()
->select([$this->db->quoteName('difficulty'), 'COUNT(*) AS ' . $this->db->quoteName('total')])
->from($this->db->quoteName('#__tallpine_trails'))
->where($this->db->quoteName('state') . ' = :state')
->bind(':state', $state, ParameterType::INTEGER)
->group($this->db->quoteName('difficulty'));
$rows = $this->db->setQuery($query)->loadAssocList();
$io->table(['Difficulty', 'Trails'], array_map(static fn ($r) => [$r['difficulty'], $r['total']], $rows));
return Command::SUCCESS;
}
}
Source: Joomla Manual: Database
The query uses the table prefix placeholder, quoteName() and a bound parameter, as in
Database API. The command does not call Factory::getDbo(), which was removed from
the 6.x API.
Running it
# The command is listed once the plugin is installed and enabled
php cli/joomla.php list tallpine
# Rehearse, then run for real
php cli/joomla.php tallpine:import --folder=/srv/gpx --category=12 --dry-run
php cli/joomla.php tallpine:import --folder=/srv/gpx --category=12 --state=1 -v
# Update existing trails without a prompt, quietly, and test the result
php cli/joomla.php -q -n tallpine:import --folder=/srv/gpx --category=12 --update
echo "exit code: $?"
# Report
php cli/joomla.php tallpine:stats
Source: Joomla User Guide: Using the CLI
After a bulk import the Smart Search index is out of date, because the Smart Search - Content plugin reacts to saves in
the back end only. Rebuild it with php cli/joomla.php finder:index (see
Smart Search). How a component integrates categories and custom fields, which an
importer must respect, is covered in Categories,
custom fields and workflow integration.
Running the command in cron
The import is normally started by a person, but a nightly job that picks up files dropped into a folder by a field app is a legitimate use. The practical rules are the ones in the CLI page, plus three that concern your own command.
# /etc/cron.d/tallpine-import (runs as the web user)
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=webmaster@tallpine.example
# Every night at 03:10, skip the run if the previous one is still active
10 3 * * * www-data flock -n /run/lock/tallpine-import.lock /usr/local/bin/php /var/www/tallpine/cli/joomla.php -q -n --live-site=https://www.tallpine.example tallpine:import --folder=/srv/gpx/incoming --category=12 --state=0 || echo "tallpine:import exited with $?"
Source: Joomla User Guide: Using the CLI
-
-n(--no-interaction) guarantees that aconfirm()never waits for a terminal that does not exist.-qkeepscronfrom mailing a success banner, and the||branch turns a non-zero exit code into the only mail the operator gets. A failing command printed with-qshows nothing, so the wrapper above echoes the exit code. -
flock -nprevents two overlapping imports. A command that writes the same rows must not run twice at once, and neither Joomla nor Symfony Console provides a lock for a plain command (the scheduler has one for tasks). -
Run as the web server’s user. A command that creates files or rows as
rootleaves cache entries and log files that the site cannot write. -
The command-line PHP has its own
php.ini. A large GPX set needsmemory_limit(php -d memory_limit=512M ...), and the extensions the command needs (simplexml,mbstring) must be loaded in the CLI as well as in the web SAPI. -
Pass
--live-sitewhen the command sends mail or builds URLs; the CLI has noHTTP_HOST. The import itself does not need it, but plugins of thesystemgroup that react to content changes may. -
In Docker, run the command with
docker compose exec -u www-dataagainst the site container, as in Local development with Docker; for a one-off job in a pipeline, the same image started with the command as its argument avoids a long-lived container.
A command that should run on the Joomla timetable rather than the operating system’s can be called from a task plugin (see Scheduled task plugins); the better design is to put the work in a service class that both the command and the task call. Backend schedulers outside Joomla are compared in cron expressions and the rest of that section.
Testing a command
Because a command is an object with an execute() method, it can be tested without a web server and without starting the
whole CLI. Build the input and output yourself, run the command and assert on the exit code and the captured output.
<?php
namespace Tallpine\Tests\Unit\Command;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Tallpine\Plugin\Console\Tallpine\Command\ImportTrailsCommand;
final class ImportTrailsCommandTest extends TestCase
{
#[Test]
public function aMissingFolderIsInvalidUsage(): void
{
$command = new ImportTrailsCommand(12);
$output = new BufferedOutput();
$exit = $command->execute(
new ArrayInput(['--folder' => '/does/not/exist', '--category' => '12']),
$output
);
$this->assertSame(2, $exit);
$this->assertStringContainsString('readable folder', $output->fetch());
}
}
execute() binds the ArrayInput against the command’s own definition; no application is needed until the command
calls getApplication(). Test the failure paths that return before that call as above, test GpxReader on its own with
fixture files, and cover the paths that call bootComponent() in an integration test against a running site (the
Docker Compose stack of the Testing extensions page). General PHPUnit practice is
in Testing with PHPUnit and Pest. Attributes such as
#[Test] replace the old @test annotation in PHPUnit 10 and later.
Security of console commands
-
Shell access is Super User access. The CLI performs no login and no ACL check: any user who can run PHP against
configuration.phpcan run every command. A command that creates content, deletes data or changes configuration is as powerful as the person who can start it, so restrict who has a shell on the web root, and keep command files and thecli/script unreadable by other system users. -
Treat arguments and files as untrusted. An importer takes input from folders that other people write into. The GPX reader above limits the size, refuses a
DOCTYPE(which blocks entity expansion and external entity attacks), passesLIBXML_NONETso the parser never fetches a network resource, and truncates the title. Never build a path from data inside a file, and never pass an argument toshell_exec()orexec()withoutescapeshellarg(). -
Use the Table API, the query builder and bound parameters. A command is not a reason to concatenate SQL; the rules of Security in extensions apply unchanged.
-
Do not print secrets. Output goes to terminals,
cronmails and CI logs. A command that reads an API token fromconfiguration.phpor from an environment variable must not echo it, not even at-vvv. -
Constrain production. The
guardImportlistener above disables a destructive command unless an environment variable opts in. The same listener can write an audit line (who, when, which arguments) to the Joomla log for every command. -
A plugin can override a core command.
addCommand()replaces a command of the same name, so a malicious or careless console plugin could replaceuser:addordatabase:import. Install console plugins only from sources you trust, and checkphp cli/joomla.php listafter installing one. -
Run as the web user, never as
root, and keep the--no-interactionflag in automation so that no prompt can be answered by a misdirected input.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
The plugin is not installed, not enabled, not in the group |
The plugin is listed but its commands are missing |
|
|
The |
|
|
|
|
|
|
|
|
The command works by hand and fails under |
Different |
An option such as |
It clashes with a global option. Rename your shortcut. |
|
Only |
Output is lost or duplicated in the log |
A command wrote with |
Wrong host in e-mailed links |
The CLI has no host. Add |
Version notes
-
The Symfony Console based CLI arrived with Joomla 4.0; before that, commands were scripts that extended a CMS
CliApplication. -
Joomla 4 and 5 used earlier major versions of the
joomla/consolepackage and of Symfony Console; Joomla 6.2 requiresjoomla/console4.0 and Symfony Console 7 (composer.jsonof the 6.2 source tree). TheAbstractCommandAPI used on this page is the one in that package, and$defaultNameis still the Framework’s way to name a command. Symfony’s own#[AsCommand]attribute applies to the SymfonyCommandclass, not toAbstractCommand. -
Joomla 5.0 introduced concrete event classes for CMS events and the
SubscriberInterfacestyle for plugins. The console lifecycle events are Framework events with their own event classes and have noon...plugin event name. -
Joomla 6.0 removed the CMS
CliApplicationandBaseApplication,\Joomla\CMS\Inputand theJPATH_PLATFORMconstant from the default code, keeping them only in the Behaviour - Backward Compatibility 6 plugin until 7.0. A command that still relies onJPATH_PLATFORMmust use\defined('_JEXEC') or die;. -
The
$appand$dbplugin properties are filled in byCMSPluginwith a deprecation notice and are no longer detected from Joomla 7.0, andFactory::getDbo()is gone from the 6.x API. Inject the database throughDatabaseAwareTrait. -
Joomla 6.1 added commands for enabling and disabling extensions (
extension:enable,extension:disable) next to the olderextension:list,extension:installandextension:remove; names used in your own plugin should avoid those prefixes.
Where older books and tutorials are outdated
-
Developing Extensions for Joomla! 5 (Cámara Mora, 2023) shows plugins and console commands written for Joomla 5.0. Its plugin examples read the database through a
$dbproperty thatCMSPluginfilled in, take events as genericEvent::getArgument()arrays, and fetch services withFactory::getLanguage()orget('DatabaseDriver'); in Joomla 6 the plugin receives its database throughDatabaseAwareTraitand its application throughgetApplication(), events are concrete classes, and the constructor takes the configuration array shown above. The command-class pattern itself (AbstractCommand,doExecute(),SymfonyStyle) is unchanged. -
Joomla! 4 Masterclass (Marzo, 2023) lists core CLI commands and shows
cli/joomla.phpfrom the user’s side. It predates the 5.x and 6.x additions (extension:*,core:autoupdate:*,update:joomla:remove-old-files) and shows Joomla 4 as the target, so verify names withliston the installed version. -
Older blog posts and Stack Exchange answers show a Joomla 3 script extending
JApplicationClithat is started withphp cli/script.php, or a Joomla 4cli/class extending the CMSCliApplication. Both describe an end-of-life platform; rewrite them as console plugins. -
Tutorials that copy the Developer Manual’s Hello World example without changes use the older plugin constructor, and tutorials that write
return self::SUCCESS;in anAbstractCommanddescendant do not run.
Where to go next
-
Joomla command-line interface covers the commands that ship with Joomla and how to schedule them.
-
Scheduled task plugins is the timetable-driven alternative.
-
Plugin development and events covers the other plugin groups.
-
Testing extensions and Coding standards and static analysis cover how the
plg_console_tallpinepackage is checked in CI.