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 --help pages, and exit codes that cron and 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

flowchart TD A[php cli/joomla.php tallpine:import] --> B[ConsoleApplication.execute] B --> C[Import plugin groups: behaviour, system, console] C --> D[plg_console_tallpine service provider builds the plugin] D --> E[Plugin is added as a subscriber] E --> F[application.before_execute event] F --> G[Plugin.registerCommands calls addCommand] G --> H[Symfony input is matched to the command name] H --> I[console.before_command_execute event] I --> J[AbstractCommand.execute binds input, then doExecute] J --> K[Exit code returned to the shell]

The steps that matter to a plugin author:

  1. ConsoleApplication::execute() (class Joomla\CMS\Application\ConsoleApplication) loads the extension namespace map, populates the HTTP host, and then calls PluginHelper::importPlugin() for the three groups on its own dispatcher. For each enabled plugin of those groups Joomla loads services/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.

  2. 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, calls addCommand() for each command. If the handler ran later, the requested command would already have been looked up and reported as undefined.

  3. addCommand(AbstractCommand $command) initialises the Framework command store, skips a command whose isEnabled() 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 a LogicException.

  4. The application then matches the typed name against the store (with unambiguous abbreviations such as tallpine:imp and with the namespace prefix, as Symfony does), dispatches console.before_command_execute, runs the command and dispatches console.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 of plugin= on the services folder). 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.ini language file carries the name and description shown in the plugin manager and the installer; the .ini file 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;
            }
        );
    }
};
  • PluginHelper::getPlugin('console', 'tallpine') returns the plugin’s database record, including the params string that CMSPlugin turns into $this->params.

  • Factory::getApplication() returns the running application. On the command line that is the ConsoleApplication, not a web application. This is the one place where the Factory is still used: it is how every core plugin hands the application to the plugin.

  • setDatabase() comes from DatabaseAwareTrait. Declaring the trait on the plugin and injecting the connection here replaces the pre-5 habit of a $db property that CMSPlugin filled in with the removed Factory::getDbo(). The CMSPlugin constructor still detects a property called $app or $db and 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 named onSomething that CMSPlugin registers 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_EXECUTE is application.before_execute, and ConsoleEvents::BEFORE_COMMAND_EXECUTE is console.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 in libraries/vendor/joomla/console and joomla/application are the reference.

  • ConsoleApplication is the type check that makes addCommand() safe. The same plugin class could in principle be imported by another application, and getApplication() is typed as the general CMS application interface.

  • The handlers take the event object or nothing. A handler for application.before_execute needs no argument. A handler for console.before_command_execute receives a BeforeCommandExecuteEvent with getCommand() and the pair disableCommand() and enableCommand(). A disabled command is not run and the application returns the exit code 113 (BeforeCommandExecuteEvent::RETURN_CODE_DISABLED). The guard above makes a production site refuse tallpine:import unless 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 with getDatabase(). The class contains no $this->app and no Factory::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

protected static $defaultName

The command name, for example tallpine:import. Read by the constructor through reflection, so it must be declared on the concrete class and not only on a parent. Pass a name to the constructor instead to register the same class under different names.

configure()

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.

initialise(InputInterface $input, OutputInterface $output)

Optional hook run after the input is bound and before doExecute(). Use it to read options into properties or to ask for missing values.

doExecute(InputInterface $input, OutputInterface $output): int

Abstract. The work of the command. Return an exit code.

execute()

Final plumbing: merges the application’s global options, binds and validates the input, calls initialise(), then doExecute(). Do not override it; override doExecute().

getApplication()

The Joomla\Console\Application the command was added to, or an UnexpectedValueException when none is set. On the CMS command line this is the ConsoleApplication.

setDescription(), setHelp(), setHidden(), setAliases()

Metadata. In the help text, %command.name% and %command.full_name% are replaced with the command name and with the script and the name.

isEnabled()

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

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 $defaultName and calls configure(), so state that configure() needs must be assigned before the parent call.

  • Core commands that need the container implement ContainerAwareInterface and use ContainerAwareTrait, 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 with bootComponent().

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

addArgument('name', InputArgument::REQUIRED)

Positional, must be present. Missing it prints an error and the usage line.

addArgument('name', InputArgument::OPTIONAL, $desc, $default)

Positional, may be left out; falls back to the default.

addArgument('names', InputArgument::IS_ARRAY)

Collects the remaining positional values; must be last.

addOption('dry-run', null, InputOption::VALUE_NONE)

A flag; getOption('dry-run') is true when present.

addOption('folder', 'f', InputOption::VALUE_REQUIRED)

--folder=/data/gpx or -f /data/gpx; the value is required when the option is used.

addOption('category', 'c', InputOption::VALUE_OPTIONAL, $desc, 0)

The value may be omitted (--category alone yields the default).

addOption('tag', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY)

Repeatable: --tag=dog --tag=family.

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

title(), section()

Headings. Use title() once at the top.

text(), listing(), writeln()

Plain lines and bullet lists.

success(), warning(), error(), note()

Coloured, boxed result messages. error() writes a block that stands out in logs.

table(), definitionList()

Aligned tables of rows, for reports.

progressStart(), progressAdvance(), progressFinish()

A progress bar. It is drawn only on a terminal; the plain output of a redirected run has no bar.

confirm(), ask(), choice(), askHidden()

Interactive questions. With --no-interaction they return the default, so a script never blocks.

getErrorStyle()

A style bound to standard error, so diagnostics do not pollute the output of a pipe.

isQuiet(), isVerbose(), isVeryVerbose(), isDebug()

Test the -q, -v, -vv and -vvv flags and print detail only when asked.

Good output habits for a command that cron runs:

  • Print nothing on success unless asked. With -q the style prints nothing. Without it, keep a single success() line and use isVerbose() 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) returns Command::FAILURE even 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 .ini files 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 before parent::__construct() runs configure(), so they are available if configure() reads them. They come from the plugin’s parameters in registerCommands().

  • 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 implements MVCFactoryServiceInterface exposes getMVCFactory(). 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 the Table methods signal failures with exceptions (the setError() convention is on its way out: core stops calling it in 7.0), so do not test a boolean return and read getError().

  • A dry run goes as far as bind() and stops before check() and store(). A more thorough dry run also calls check(), which validates but does not write.

  • Interactive confirmation is skipped when there is no terminal. isInteractive() is false under cron, in a pipe and with --no-interaction, so an unattended --update run 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;
    }
}

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;
    }
}

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

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 $?"
  • -n (--no-interaction) guarantees that a confirm() never waits for a terminal that does not exist. -q keeps cron from 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 -q shows nothing, so the wrapper above echoes the exit code.

  • flock -n prevents 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 root leaves cache entries and log files that the site cannot write.

  • The command-line PHP has its own php.ini. A large GPX set needs memory_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-site when the command sends mail or builds URLs; the CLI has no HTTP_HOST. The import itself does not need it, but plugins of the system group that react to content changes may.

  • In Docker, run the command with docker compose exec -u www-data against 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.php can 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 the cli/ 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), passes LIBXML_NONET so 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 to shell_exec() or exec() without escapeshellarg().

  • 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, cron mails and CI logs. A command that reads an API token from configuration.php or from an environment variable must not echo it, not even at -vvv.

  • Constrain production. The guardImport listener 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 replace user:add or database:import. Install console plugins only from sources you trust, and check php cli/joomla.php list after installing one.

  • Run as the web user, never as root, and keep the --no-interaction flag in automation so that no prompt can be answered by a misdirected input.

Common errors and pitfalls

Symptom Cause and fix

Command "tallpine:import" is not defined.

The plugin is not installed, not enabled, not in the group console, or its namespace map is wrong. Check System → Manage → Plugins, the <namespace> element and list. A fatal error in the plugin file is also reported as an undefined command when the application catches it.

The plugin is listed but its commands are missing

registerCommands() is subscribed to the wrong event, or the subscribed method does not exist. The event is ApplicationEvents::BEFORE_EXECUTE; a later event is too late.

Class "...\ImportTrailsCommand" not found

The <namespace path="src"> of the manifest does not match the PHP namespace, or the file was not copied. Namespace-to-folder mapping is case-sensitive on Linux.

The command class "..." does not have a name.

$defaultName is declared on a parent class or missing. Declare protected static $defaultName on the concrete class, or pass a name to the constructor.

Undefined constant ...::SUCCESS

AbstractCommand has no exit code constants. Import Symfony\Component\Console\Command\Command and use Command::SUCCESS.

Application not set in ...

getApplication() was called from the constructor or configure(), before addCommand() attached the application. Move the code to doExecute().

Call to undefined method ...::addCommand()

getApplication() returned the web application. The plugin was loaded by another application, or a test passed the wrong one. Guard with instanceof ConsoleApplication.

The command works by hand and fails under cron

Different PATH, PHP binary, php.ini, user or working directory. Use absolute paths, run as the web user, and log the exit code.

An option such as -v or -q does nothing or is rejected

It clashes with a global option. Rename your shortcut.

Content plugins did not run after the import

Only behaviour, system and console plugins are imported. If the command needs a content plugin, import it explicitly with PluginHelper::importPlugin('content') before saving.

Output is lost or duplicated in the log

A command wrote with echo or print. Write through OutputInterface, so that -q, redirection and verbosity are honoured.

Wrong host in e-mailed links

The CLI has no host. Add --live-site or set $live_site in configuration.php.

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/console package and of Symfony Console; Joomla 6.2 requires joomla/console 4.0 and Symfony Console 7 (composer.json of the 6.2 source tree). The AbstractCommand API used on this page is the one in that package, and $defaultName is still the Framework’s way to name a command. Symfony’s own #[AsCommand] attribute applies to the Symfony Command class, not to AbstractCommand.

  • Joomla 5.0 introduced concrete event classes for CMS events and the SubscriberInterface style for plugins. The console lifecycle events are Framework events with their own event classes and have no on... plugin event name.

  • Joomla 6.0 removed the CMS CliApplication and BaseApplication, \Joomla\CMS\Input and the JPATH_PLATFORM constant from the default code, keeping them only in the Behaviour - Backward Compatibility 6 plugin until 7.0. A command that still relies on JPATH_PLATFORM must use \defined('_JEXEC') or die;.

  • The $app and $db plugin properties are filled in by CMSPlugin with a deprecation notice and are no longer detected from Joomla 7.0, and Factory::getDbo() is gone from the 6.x API. Inject the database through DatabaseAwareTrait.

  • Joomla 6.1 added commands for enabling and disabling extensions (extension:enable, extension:disable) next to the older extension:list, extension:install and extension: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 $db property that CMSPlugin filled in, take events as generic Event::getArgument() arrays, and fetch services with Factory::getLanguage() or get('DatabaseDriver'); in Joomla 6 the plugin receives its database through DatabaseAwareTrait and its application through getApplication(), 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.php from 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 with list on the installed version.

  • Older blog posts and Stack Exchange answers show a Joomla 3 script extending JApplicationCli that is started with php cli/script.php, or a Joomla 4 cli/ class extending the CMS CliApplication. 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 an AbstractCommand descendant do not run.

Where to go next