Joomla Plugin Development and Events

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 Joomla plugin is a small class that listens to named events and runs when the core, a component or another extension announces that event. Everything that is not a screen of its own in Joomla is built that way: page caching, the editors, captchas, authentication, search indexing, the URL redirector, the scheduled tasks, the Web Services API routes, the Schema.org markup and the console commands are all plugins. Writing a plugin is therefore the most common first step in extending Joomla, and the event system explained here is also what component and module code uses to let other extensions interfere with it. This page covers the folder layout of a plugin, the manifest, the CMSPlugin base class and the SubscriberInterface contract, the concrete event classes added in Joomla 5.0, results and immutable events, getting the application and the database, every plugin group, the setup events used by captchas and editors, how to dispatch your own events, and a complete content plugin for the Tallpine Hiking Club. It also lists the errors that come from copying Joomla 3 and Joomla 4 era examples.

It was written against Joomla 6.2 (current, PHP 8.3 or newer) and checked against the Joomla Developer Manual and the 6.2 source tree on 2026-10-30. Joomla 5.4 receives security fixes only and Joomla 4.x and 3.x are end of life; where those releases behave differently the text says so. The examples belong to the fictitious mountain club used across this sub-section: package pkg_tallpine, component com_tallpine (namespace Tallpine\Component\Tallpine), module mod_tallpine_nexthikes and the plugins plg_content_tallpine_conditions, plg_webservices_tallpine, plg_task_tallpine_trailsync and plg_console_tallpine. Related pages go deeper on one side of this topic: Namespaces, autoloading and dependency injection explains the container that builds a plugin, Extension types, manifests and packaging shows the whole manifest, Scheduled task plugins and Console command plugins cover two specialised plugin groups, and Extending the Web Services API covers the webservices group. PHP syntax itself (interfaces, traits, attributes) is not repeated; see Inheritance, interfaces and abstract classes and Traits.

What a plugin is and who writes one

A plugin is an extension of type plugin that belongs to one group (also called its type or folder). The group decides which events the plugin is loaded for and where its files live, plugins/<group>/<element>. A site builder meets plugins in System → Manage → Plugins, where each one can be enabled, disabled, ordered and configured through the parameters declared in its manifest. A developer writes a plugin when behaviour has to be added without changing core files:

  • reacting to the lifecycle of the page (onAfterRoute, onBeforeCompileHead, onAfterRender)

  • changing content before it is shown (onContentPrepare) or when it is saved (onContentBeforeSave, onContentAfterSave)

  • extending forms (onContentPrepareForm) and the Web Services API (onBeforeApiRoute)

  • answering AJAX calls (onAjax<Name>) for a JavaScript widget

  • registering a service, such as a captcha or an editor, into a registry that core owns

  • implementing a provider contract for a group such as authentication, scheduled tasks or Smart Search

Plugins run in every application the site has: the site, the administrator, the API and the command line. There is no separate site plugin and administrator plugin, so a plugin that should act on one client only has to ask $this->getApplication()->isClient('site') itself, as the example further down does.

The event model in one picture

Joomla uses the event dispatcher of the Joomla Framework (joomla/event). Three things take part:

  • the dispatcher, an object each application owns, which stores the listeners per event name

  • the subscribers (plugins), which tell the dispatcher which event names they want, which of their methods handles each one and with what priority

  • the event object, created by the code that announces the event, which carries named arguments, may collect results and can be stopped

The code that announces an event first imports the plugins of the group (PluginHelper::importPlugin('content')), which loads the enabled plugins of that group from the database in their ordering, boots each one through its services/provider.php and registers it with the dispatcher. Then it creates the event and calls $dispatcher->dispatch($name, $event), and the dispatcher calls every listener registered for that name, highest priority first, handing each the same event object.

sequenceDiagram participant V as Article HtmlView participant H as PluginHelper participant D as Dispatcher participant P1 as System plugin (HIGH) participant P2 as Tallpine conditions (NORMAL) participant P3 as Page cache (LOW) V->>H: importPlugin('content', null, true, dispatcher) H->>H: load enabled plugins ordered by ordering H->>P2: boot via services/provider.php H->>D: addSubscriber(plugin) using getSubscribedEvents() V->>D: dispatch('onContentPrepare', ContentPrepareEvent) D->>P1: onContentPrepare(event) P1-->>D: returns, event unchanged D->>P2: onContentPrepare(event) P2->>P2: event.getContext() and event.getItem() P2-->>D: appends trail conditions to item text D->>P3: onContentPrepare(event) P3-->>D: returns D-->>V: event, item text now includes the conditions

Two consequences follow from the picture. First, the event object is shared: a later listener sees what an earlier one did to the arguments that are objects, which is how onContentPrepare plugins change article text. Second, only the plugins of the group that was imported take part. A plugin in a group that nobody imported never hears the event, even when it is enabled, and that is the cause of most "my plugin does nothing" reports (see the section on common errors).

Anatomy of a plugin

A Joomla 6 plugin has this layout. The example is the Tallpine content plugin, which in the installable package sits at plugins/content/tallpine_conditions after installation:

plg_content_tallpine_conditions/
|-- tallpine_conditions.xml            manifest
|-- services/
|   `-- provider.php                   builds the plugin object
|-- src/
|   `-- Extension/
|       `-- TallpineConditions.php     the plugin class
`-- language/
    `-- en-GB/
        |-- plg_content_tallpine_conditions.ini
        `-- plg_content_tallpine_conditions.sys.ini

Three conventions hold the structure together:

  • the element (the folder name and the plugin="..." attribute of the manifest) is lowercase, for example tallpine_conditions, and the extension is known in the database as plg_content_tallpine_conditions

  • classes live under src/ and are mapped to a namespace declared in the manifest, here Tallpine\Plugin\Content\TallpineConditions; by convention the class that implements the plugin sits in the Extension sub-namespace

  • services/provider.php returns a service provider that binds PluginInterface to a factory building the plugin; core loads this file when the plugin is imported

The old layout, a single tallpine_conditions.php containing class PlgContentTallpine_conditions extends CMSPlugin, is still loaded for backward compatibility in Joomla 6, but it cannot be namespaced, cannot receive injected services and depends on the legacy listener emulation that Joomla 7 removes. New plugins should not be written that way.

The manifest

The manifest is the XML file named after the element. For a plugin it declares the group, the namespace, the files and the configuration form:

<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade">
    <name>plg_content_tallpine_conditions</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>
    <authorEmail>web@tallpine.example</authorEmail>
    <authorUrl>https://tallpine.example</authorUrl>
    <version>1.0.0</version>
    <description>PLG_CONTENT_TALLPINE_CONDITIONS_XML_DESCRIPTION</description>
    <namespace path="src">Tallpine\Plugin\Content\TallpineConditions</namespace>
    <files>
        <folder plugin="tallpine_conditions">services</folder>
        <folder>src</folder>
    </files>
    <languages>
        <language tag="en-GB">language/en-GB/plg_content_tallpine_conditions.ini</language>
        <language tag="en-GB">language/en-GB/plg_content_tallpine_conditions.sys.ini</language>
    </languages>
    <config>
        <fields name="params">
            <fieldset name="basic">
                <field
                    name="show_closed_only"
                    type="radio"
                    label="PLG_CONTENT_TALLPINE_CONDITIONS_CLOSED_ONLY_LABEL"
                    layout="joomla.form.field.radio.switcher"
                    default="0"
                    filter="integer"
                    >
                    <option value="0">JNO</option>
                    <option value="1">JYES</option>
                </field>
                <field
                    name="css_class"
                    type="text"
                    label="PLG_CONTENT_TALLPINE_CONDITIONS_CSS_CLASS_LABEL"
                    default="trail-conditions"
                    validate="CssIdentifier"
                />
            </fieldset>
        </fields>
    </config>
</extension>

The details that matter:

  • type="plugin" and group="content" decide the folder the files are copied to, plugins/content/tallpine_conditions. The group is a free string: a custom group such as tallpine is created by the first plugin that names it.

  • method="upgrade" lets the same package install over an older version without uninstalling first, which is what every core plugin does.

  • The plugin="tallpine_conditions" attribute on one folder (or filename) element names the plugin element, which is also the folder name under plugins/content.

  • <namespace path="src"> is what makes Tallpine\Plugin\Content\TallpineConditions\... resolve to src/.... Without it the autoload map has no entry and the provider.php fails with "Class not found". The map is regenerated when an extension is installed, updated or uninstalled.

  • The <config> block is the plugin’s settings form, stored as JSON in the params column of the extension record and exposed as $this->params (a Joomla\Registry\Registry). Always give fields a filter or a validate rule; the value reaches the page and must be treated as input.

  • The .sys.ini file holds the strings shown in the extension manager and installer before the plugin is loaded.

The manifest, packaging into pkg_tallpine and the update server are on Extension types, manifests and packaging.

services/provider.php

The provider is the factory of the plugin. It returns an anonymous class implementing ServiceProviderInterface:

<?php

/**
 * @package     Tallpine.Plugin
 * @subpackage  Content.tallpine_conditions
 *
 * @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\Content\TallpineConditions\Extension\TallpineConditions;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            PluginInterface::class,
            $container->lazy(TallpineConditions::class, function (Container $container) {
                $plugin = new TallpineConditions(
                    (array) PluginHelper::getPlugin('content', 'tallpine_conditions')
                );
                $plugin->setApplication(Factory::getApplication());
                $plugin->setDatabase($container->get(DatabaseInterface::class));

                return $plugin;
            })
        );
    }
};

The same provider is explained line by line in Namespaces, autoloading and dependency injection. What is specific to plugins:

  • PluginHelper::getPlugin() returns the extension row (name, type, params), and passing it to the constructor as the $config array is how $this->params, $this->_name and $this->_type are filled.

  • lazy() delays the construction until the first call, which keeps a site with fifty enabled plugins cheap. On PHP 8.4 the Manual describes it as using lazy objects.

  • Every service the plugin needs is set here, through setters. The plugin never reaches into the container itself.

  • The first argument of the plugin constructor used to be the dispatcher (new Foo($container->get(DispatcherInterface::class), $config)). CMSPlugin still accepts it in Joomla 6 but raises a deprecation notice, and the class stops implementing DispatcherAwareInterface in 7.0.

The Extension class

The plugin class extends CMSPlugin and implements SubscriberInterface. Section "Subscribing to events" below shows the full Tallpine class; its skeleton is:

<?php

namespace Tallpine\Plugin\Content\TallpineConditions\Extension;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Event\SubscriberInterface;

\defined('_JEXEC') or die;

final class TallpineConditions extends CMSPlugin implements SubscriberInterface
{
    use DatabaseAwareTrait;

    public static function getSubscribedEvents(): array
    {
        return ['onContentPrepare' => 'onContentPrepare'];
    }
}

Core marks its own plugin classes final: of the plugins in the 6.2 source tree, all but a handful of exceptions (for example the multi-factor authentication provider plugins and two quick-icon plugins) are declared final class. The practical meaning for third-party code is that you cannot extend a core plugin to change its behaviour. To adjust what a core plugin does, subscribe to the same event with a higher or lower priority and alter the event arguments, or replace the plugin with your own. Make your plugins final too unless you really design them as a base class: it documents that the public surface is the set of event handlers.

Subscribing to events

getSubscribedEvents()

SubscriberInterface has a single method, a static one that returns the map from event name to handler:

public static function getSubscribedEvents(): array
{
    return [
        // event name => method name
        'onContentPrepare'     => 'onContentPrepare',

        // event name => [method name, priority]
        'onAfterRender'        => ['addConditionsBanner', \Joomla\Event\Priority::LOW],

        // event name => [method name, priority]
        'onContentBeforeSave'  => ['validateClosureDates', \Joomla\Event\Priority::HIGH],
    ];
}

Each event takes exactly one entry: a method name, or a [method, priority] pair. The Joomla dispatcher’s addSubscriber() reads only that pair, so a list of several handlers for the same event is not supported. A plugin that needs two steps on one event calls the second method from the first handler.

Because the method is static and is called when the plugin is imported, it must not depend on the state of the object: no parameters, no database, no conditional logic that needs $this. A plugin that wants to subscribe depending on its parameters subscribes always and returns early in the handler.

The method name does not have to equal the event name. Core plugins use both forms: onContentPrepare handled by a method called onContentPrepare, and onCaptchaSetup handled by setupCaptcha. Descriptive method names are easier to test and to read when one plugin handles many events.

Priorities

The dispatcher calls listeners from the highest priority to the lowest. Priorities are the integer constants of Joomla\Event\Priority:

Constant Value Typical use

Priority::MAX

3

Must run before anything else, for example to short-circuit

Priority::HIGH

2

Validation that should run before other handlers

Priority::ABOVE_NORMAL

1

Slightly early

Priority::NORMAL

0

Default when only a method name is given

Priority::BELOW_NORMAL

-1

Core’s Schema.org plugins use it so that custom types can win

Priority::LOW

-2

Core’s page cache plugin uses it for onAfterRender and onAfterRespond so that it stores the final HTML

Priority::MIN

-3

Must run after everything else

Listeners with equal priority run in the order they were registered, and registration follows the ordering column of the plugin rows, which is the order shown in System → Manage → Plugins. A site builder can therefore reorder same-priority plugins by dragging them, but should not have to: set a priority explicitly whenever the order matters and use NORMAL only when it does not.

Priorities are a map entry, not a promise about other extensions. Two plugins that both ask for MAX fall back to registration order. An event can also be stopped ($event->stop()), after which no further listener is called. Core events that implement it document it rarely, so use it only for events you dispatch yourself.

Legacy listeners and why not to use them

Before SubscriberInterface, CMSPlugin::registerListeners() scanned the public methods starting with on, guessed whether they took an event object or the old positional arguments, and wrapped the old style in a closure. A plugin that did not implement SubscriberInterface still goes through that code in Joomla 6.2: registerListeners() raises E_USER_DEPRECATED ("The plugin should implement SubscriberInterface"), and the property $allowLegacyListeners (deprecated since 4.3) controls the guessing. Joomla 7 removes the emulation. Those plugins are also slower, because every one is inspected with reflection on every request.

Older books and tutorials teach public function onContentPrepare($context, &$article, &$params, $page = 0) with the method’s own signature as the contract. Developing Extensions for Joomla! 5 (2023) is much closer to current practice than Joomla 3 material, yet it still shows a non-namespaced CMSPlugin web services plugin with onBeforeApiRoute(&$router) and $db as a plugin property. Since Joomla 5.0 the event handler receives a single event object (onBeforeApiRoute(BeforeApiRouteEvent $event), then $event->getRouter()), and $app/$db come from setters. The same applies to Joomla! 4 Masterclass (2023), which targets an end-of-life release.

Concrete event classes (5.0 and later)

The problem they solve

Joomla 4 introduced event objects, but they were generic: an event had a name and an array of arguments, and a handler read them with $event->getArgument('context') or, worse, by position. Nothing told the editor, the static analyser or the reader which arguments an event carries, of which type, or whether they may be changed. Joomla 5.0 introduced concrete event classes in the Joomla\CMS\Event namespace, one class per event (or per family of events), with typed getters, argument validation in the constructor and documented results.

The mapping from event name to class is held by Joomla\CMS\Event\CoreEventAware, and AbstractEvent::create() uses it. A few examples from the 6.2 source:

Event name Concrete class

onAfterInitialise

Joomla\CMS\Event\Application\AfterInitialiseEvent

onBeforeCompileHead

Joomla\CMS\Event\Application\BeforeCompileHeadEvent

onBeforeApiRoute

Joomla\CMS\Event\Application\BeforeApiRouteEvent

onContentPrepare

Joomla\CMS\Event\Content\ContentPrepareEvent

onContentBeforeDisplay

Joomla\CMS\Event\Content\BeforeDisplayEvent

onContentPrepareForm

Joomla\CMS\Event\Model\PrepareFormEvent

onContentBeforeSave

Joomla\CMS\Event\Model\BeforeSaveEvent

onContentAfterSave

Joomla\CMS\Event\Model\AfterSaveEvent

onUserAuthenticate

Joomla\CMS\Event\User\AuthenticationEvent

onUserLogin

Joomla\CMS\Event\User\LoginEvent

onCaptchaSetup

Joomla\CMS\Event\Captcha\CaptchaSetupEvent

onEditorSetup

Joomla\CMS\Event\Editor\EditorSetupEvent

onEditorButtonsSetup

Joomla\CMS\Event\Editor\EditorButtonsSetupEvent

onAjax<Name>

Joomla\CMS\Event\Plugin\AjaxEvent

The Manual’s event index lists every event with its default plugin group and the version that introduced it, and links to a page per event describing the arguments. Use it to find the event name; use the class to find the types.

Getters versus getArgument()

ContentPrepareEvent is the best example. The code that announces the event, HtmlView of com_content, builds it with an argument array and the event reshapes the names:

$contentEventArguments = [
    'context' => 'com_content.article',
    'subject' => $item,
    'params'  => $item->params,
    'page'    => $offset,
];

$dispatcher->dispatch(
    'onContentPrepare',
    new Content\ContentPrepareEvent('onContentPrepare', $contentEventArguments)
);

A handler can read the arguments in two ways:

use Joomla\CMS\Event\Content\ContentPrepareEvent;

public function onContentPrepare(ContentPrepareEvent $event): void
{
    // Concrete getters: typed, documented, autocompleted
    $context = $event->getContext();   // string, for example 'com_content.article'
    $item    = $event->getItem();      // object, the article (the 'subject' argument)
    $params  = $event->getParams();    // Joomla\Registry\Registry
    $page    = $event->getPage();      // ?int

    // Generic access: no types, no autocompletion, name must match the raw key
    $sameContext = $event->getArgument('context');
    $sameItem    = $event->getArgument('subject');   // the key is 'subject', not 'item'
    $all         = $event->getArguments();           // associative array of raw arguments
}

Rules to remember:

  • Prefer the getter. Its name follows the documented argument name (getItem()), while the raw array key can be different (subject). getArgument('item') returns null, and $event['item'] returns null without a warning, which hides the mistake.

  • A getter has a return type. getContext() returns string, getParams() returns Registry; the constructor of the event coerces or rejects wrong types. ContentPrepareEvent wraps a non-Registry params value in a Registry and raises a deprecation, and Joomla 7 will throw.

  • The constructor of the event validates required arguments. ContentEvent throws a BadMethodCallException when context or subject is missing, so an extension dispatching onContentPrepare must supply both.

  • AbstractEvent::getArgument() also looks for an onGet<Name> method and, as a deprecated fallback, a get<Name> method. The getter-for-preprocessing fallback is removed in Joomla 7; new event classes write onGet<Name>.

  • Numeric access ($event->getArgument(0)) works only for backward compatibility, raises a deprecation, and does not work in Joomla 7.

Immutable events and results

Most concrete classes extend AbstractImmutableEvent. After construction you cannot add, replace or remove an argument: $event->setArgument('context', 'x') and $event['context'] = 'x' throw BadMethodCallException ("Cannot set the argument context of the immutable event …​"). Immutable does not mean read-only for the objects inside the arguments. The article in ContentPrepareEvent is an object, so $item->text .= '...' changes it for later listeners and for the page, and $event->getForm()->setFieldAttribute() changes the form. That is the intended way to modify content. The change appears in the HTTP response only; it is not written to the database unless the code that owns the object saves it.

Events that expect an answer implement ResultAwareInterface and use the ResultAware trait together with a type trait (ResultTypeStringAware, ResultTypeBooleanAware, ResultTypeArrayAware and others). A handler returns its answer through addResult(), and the type trait checks it:

use Joomla\CMS\Event\Model\BeforeSaveEvent;

public function validateClosureDates(BeforeSaveEvent $event): void
{
    if ($event->getContext() !== 'com_tallpine.trail') {
        return;
    }

    $data = $event->getData();

    if (!empty($data['closed_from']) && !empty($data['closed_until'])
        && $data['closed_until'] < $data['closed_from']) {
        $this->getApplication()->enqueueMessage(
            $this->getApplication()->getLanguage()->_('PLG_CONTENT_TALLPINE_CONDITIONS_BAD_DATES'),
            'error'
        );

        // A false result tells the model to cancel the save
        $event->addResult(false);
    }
}

The caller collects the results afterwards with $event->getArgument('result', []) and decides what they mean. In AdminModel::save() the check is in_array(false, $result, true), so any listener returning false aborts the save. For onContentBeforeDisplay the results are strings that are concatenated into the page. The result argument cannot be set directly on a result-aware event; assigning to it raises an exception on events that block it, and on immutable events the old $event['result'] = $x style is deprecated and fails in Joomla 7. Always use addResult(), or the specific update method an event offers, such as updateEventResult() on AjaxEvent and updatePackage() on the installer’s before-event.

Plugins written for Joomla 3 and 4 often return a value from the handler. That still works when the plugin goes through the legacy wrapper, which converts a returned non-null value into a result. A SubscriberInterface handler is called directly by the dispatcher and its return value is ignored, so a handler that returns true or false without addResult() silently does nothing.

Application, database and other services

Why the $app and $db properties are going away

In Joomla 3 every plugin could declare protected $app; and protected $db;, and CMSPlugin filled them with Factory::getApplication() and Factory::getDbo() in its constructor. Joomla 4.x and 5.x and 6.x still do this, but with E_USER_DEPRECATED notices ("The application should be injected through setApplication() …​" and "The database should be injected through the DatabaseAwareInterface and trait"). The auto-detection of the $app and $db properties is removed in Joomla 7.0. A plugin that still relies on $this->app or $this->db therefore works today and breaks on upgrade. Older books, including Developing Extensions for Joomla! 5, still show $db as a plugin property.

getApplication() and getDatabase()

CMSPlugin has the application built in: the provider calls setApplication() and the handler calls getApplication(). The application is typed as CMSApplicationInterface, so site-only methods need a guard such as isClient() and an instanceof check. The database comes from Joomla\Database\DatabaseAwareTrait, which gives setDatabase() and getDatabase(), and the provider must call the setter, as in the provider above. Other Aware traits exist for the services plugins commonly need:

Trait Getter Provider setter source

Joomla\Database\DatabaseAwareTrait

getDatabase()

$container->get(DatabaseInterface::class)

Joomla\CMS\User\UserFactoryAwareTrait

getUserFactory()

$container->get(UserFactoryInterface::class)

Joomla\CMS\Mail\MailerFactoryAwareTrait

getMailerFactory()

$container->get(MailerFactoryInterface::class)

Joomla\CMS\Language\LanguageFactoryAwareTrait

getLanguageFactory()

$container->get(LanguageFactoryInterface::class)

Joomla\CMS\Language\LanguageAwareTrait (already in CMSPlugin, to be removed there in 7.0)

getLanguage()

set by the base class or $app->getLanguage()

Notes that avoid the common mistakes:

  • getDatabase() throws or returns null if the provider forgot setDatabase(). Calling getDatabase() in the constructor is too early; the setters run after construction.

  • Do not call Factory::getDbo(); it is deprecated. Do not call Factory::getUser() or Factory::getLanguage() either; use $this->getApplication()->getIdentity() and ->getLanguage().

  • In an event handler, prefer what the event offers. ApplicationEvent::getApplication() (a final method) returns the application that announced the event, which is the application the event belongs to.

  • $this->params is a Registry. $this->params->get('css_class', 'trail-conditions') gives a default when the setting was never saved.

  • Language strings load lazily. protected $autoloadLanguage = true; loads plg_<group>_<element> on construction (or after onAfterInitialise if the language is not ready yet), at the cost of a file read on every request; call $this->loadLanguage() in the handler that needs it instead.

Database queries in plugins follow the rules of Database API: bind every variable, quote every identifier, and use #__ table prefixes.

The Tallpine content plugin

The Tallpine Hiking Club wants each trail report to show the current conditions (open, closed, snow on the ridge) maintained in the table #__tallpine_conditions. The plugin appends a line to the article on the public site and also validates the closure dates when a trail is saved. The provider and manifest are above; this is the plugin class:

<?php

namespace Tallpine\Plugin\Content\TallpineConditions\Extension;

use Joomla\CMS\Event\Content\ContentPrepareEvent;
use Joomla\CMS\Event\Model\BeforeSaveEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\ParameterType;
use Joomla\Event\Priority;
use Joomla\Event\SubscriberInterface;

\defined('_JEXEC') or die;

/**
 * Appends the current trail conditions to Tallpine trail articles
 * and validates closure dates when a trail is saved.
 */
final class TallpineConditions extends CMSPlugin implements SubscriberInterface
{
    use DatabaseAwareTrait;

    protected $autoloadLanguage = false;

    public static function getSubscribedEvents(): array
    {
        return [
            'onContentPrepare'    => ['appendConditions', Priority::BELOW_NORMAL],
            'onContentBeforeSave' => ['validateClosureDates', Priority::HIGH],
        ];
    }

    public function appendConditions(ContentPrepareEvent $event): void
    {
        $app = $this->getApplication();

        // Plugins run on every client; this one only decorates the public site.
        if (!$app->isClient('site')) {
            return;
        }

        $item = $event->getItem();

        if ($event->getContext() !== 'com_content.article' || !isset($item->id, $item->text)) {
            return;
        }

        $db    = $this->getDatabase();
        $query = $db->createQuery()
            ->select($db->quoteName(['status', 'note']))
            ->from($db->quoteName('#__tallpine_conditions'))
            ->where($db->quoteName('article_id') . ' = :id')
            ->bind(':id', $item->id, ParameterType::INTEGER);

        $row = $db->setQuery($query)->loadObject();

        if ($row === null) {
            return;
        }

        if ($this->params->get('show_closed_only', 0) && $row->status !== 'closed') {
            return;
        }

        $class = htmlspecialchars((string) $this->params->get('css_class', 'trail-conditions'), ENT_QUOTES, 'UTF-8');

        // Everything that comes from the database is escaped before it reaches the page.
        $item->text .= \sprintf(
            '<p class="%s"><strong>%s</strong> %s</p>',
            $class,
            htmlspecialchars((string) $row->status, ENT_QUOTES, 'UTF-8'),
            htmlspecialchars((string) $row->note, ENT_QUOTES, 'UTF-8')
        );
    }

    public function validateClosureDates(BeforeSaveEvent $event): void
    {
        if ($event->getContext() !== 'com_tallpine.trail') {
            return;
        }

        $data = $event->getData();

        if (!empty($data['closed_from']) && !empty($data['closed_until'])
            && $data['closed_until'] < $data['closed_from']) {
            $this->loadLanguage();
            $this->getApplication()->enqueueMessage(
                $this->getApplication()->getLanguage()->_('PLG_CONTENT_TALLPINE_CONDITIONS_BAD_DATES'),
                'error'
            );

            $event->addResult(false);
        }
    }
}

Points worth noticing:

  • The class is final, extends CMSPlugin, implements SubscriberInterface and uses DatabaseAwareTrait: this is the shape of every current core plugin.

  • The two handlers have descriptive names and different priorities. The decorator runs after most other onContentPrepare plugins so that it appends to text they already processed; the validator runs first so that a bad save is stopped before logging or notification plugins react.

  • The query uses a named placeholder with bind() and an explicit ParameterType, never string concatenation.

  • Output that came from the database is escaped with htmlspecialchars(). onContentPrepare output goes straight into the page, and a stored string with markup would otherwise run in the visitor’s browser.

  • onContentPrepare is also dispatched for modules, category descriptions and other contexts, which is why the first lines return early unless the context is com_content.article.

  • $item->text is the working copy of the article body. Writing to introtext or fulltext here would not reach the page for the article view and could be saved by mistake by code that later stores the object.

The language file that supports the plugin:

; plg_content_tallpine_conditions.ini
PLG_CONTENT_TALLPINE_CONDITIONS="Content - Tallpine trail conditions"
PLG_CONTENT_TALLPINE_CONDITIONS_XML_DESCRIPTION="Shows the current conditions of a trail below the article and checks closure dates."
PLG_CONTENT_TALLPINE_CONDITIONS_CLOSED_ONLY_LABEL="Show only closed trails"
PLG_CONTENT_TALLPINE_CONDITIONS_CSS_CLASS_LABEL="CSS class"
PLG_CONTENT_TALLPINE_CONDITIONS_BAD_DATES="The closing date must not be earlier than the start date."

Enabling it is done in System → Manage → Plugins, searching for "Tallpine"; the CLI is covered on Command line interface. A package installs it and, when the installer script of pkg_tallpine enables it, activates it automatically when the package manifest uses <file type="plugin" id="tallpine_conditions" group="content"> and the installer script of pkg_tallpine enables it (see Installer scripts and SQL updates).

Plugin groups

The group is the folder under plugins/. Core loads a group only when something asks for it, so a plugin in the wrong group is simply never called. The table lists the groups that exist in the 6.2 source tree plus the ones that core imports from other parts of the system:

Group What it is for Examples and typical events

system

The application lifecycle, imported on every request

onAfterInitialise, onAfterRoute, onBeforeCompileHead, onAfterRender, onAfterRespond, onError, onAjax<Name>; page cache, redirect, SEF, debug, HTTP headers, language filter

content

Items shown or stored by content components

onContentPrepare, onContentBeforeDisplay, onContentAfterDisplay, onContentBeforeSave, onContentAfterSave, onContentChangeState, onContentPrepareForm; Joomla (content), page break, vote, load module, email cloak, confirm consent

user

Account and group lifecycle

onUserBeforeSave, onUserAfterSave, onUserLogin, onUserLogout, onUserBeforeDelete, onUserAfterDelete

authentication

Verifying credentials at login

onUserAuthenticate; Joomla (database), cookie, LDAP

captcha

Bot protection providers

onCaptchaSetup (provider registration); Proof-of-Work captcha since 6.1

editors

Text editors

onEditorSetup (editor registration); TinyMCE, CodeMirror, none

editors-xtd

Buttons below an editor

onEditorButtonsSetup; article, image, menu, module, page break, read more, fields, contact

fields

Custom field types

onCustomFieldsPrepareDom, onCustomFieldsGetTypes; text, list, media, calendar, subform, user; see Custom fields

finder

Smart Search content adapters

onFinderChange, onFinderCategoryChangeState, onFinderDelete, onFinderAfterSave; see Smart Search

task

Scheduled task routines

onTaskOptionsList, onExecuteTask; see Scheduled task plugins

console

Commands for the Joomla CLI

Commands registered when the console application imports the group; see Console command plugins

webservices

Routes of the Web Services API

onBeforeApiRoute; see Extending the Web Services API

schemaorg

Schema.org types and their form fields

onSchemaPrepareForm, onSchemaBeforeCompileHead; see SEO, SEF URLs, redirects and Schema.org

behaviour

Compatibility layers owned by core, for example "Behaviour - Backward Compatibility 6"

Do not put your own plugins here (see upgrade notes)

multifactorauth, api-authentication, workflow, actionlog, privacy, quickicon, installer, extension, filesystem, media-action, sampledata

Specialised groups used by one subsystem each

Imported by that subsystem; multi-factor providers, API token and basic auth, workflow transitions, action log recorders, privacy consent and export, dashboard quick icons

Group-specific notes:

  • system plugins are imported by the application on every request, which makes them the most expensive place to put code. A handler in system that does work on every page needs a fast early return. Use a content or fields plugin when the event is content specific.

  • content plugins are imported by components just before they announce content events. PluginHelper::importPlugin('content') caches the imported group per dispatcher, so repeated calls are cheap.

  • user and authentication are imported by the login and user models. Authentication plugins answer through the authentication event and must never log the supplied password.

  • finder, task, webservices, console and schemaorg plugins implement a provider contract by base class or trait (Adapter for Smart Search, TaskPluginTrait for tasks), not only an event handler, which is why they have their own pages.

  • A custom group is created simply by using a new group value, as the Tallpine example below does with tallpine. Core does not import it; the code that announces the events must.

Joomla also supports plugins that serve JSON for JavaScript: the event onAjax<Name> fires when the request is index.php?option=com_ajax&plugin=<name>&group=<group>&format=json. com_ajax imports only that group, so it is cheap. Treat such an endpoint like any other controller: check the form token, check the user’s permission and filter the input (see Security in extensions).

Setup events: captchas and editors

Captchas and editors used to be found by name: a plugin answered onInit, onDisplay and onCheckAnswer, and the core asked for an editor with onDisplay and similar generic events. Since Joomla 5.0 a captcha, an editor and an editor button are registered in a registry, through a setup event, and then used through a typed provider object. The setup events are:

  • onCaptchaSetup, class Joomla\CMS\Event\Captcha\CaptchaSetupEvent, getter getCaptchaRegistry() returning a CaptchaRegistry

  • onEditorSetup, class Joomla\CMS\Event\Editor\EditorSetupEvent, getter getEditorsRegistry() returning an EditorsRegistryInterface

  • onEditorButtonsSetup, class Joomla\CMS\Event\Editor\EditorButtonsSetupEvent, getters getButtonsRegistry(), getEditorType(), getDisabledButtons(), getEditorId(), getAsset() and getAuthor()

Core’s Proof-of-Work captcha shows the pattern. A captcha plugin does not render anything itself; it adds a provider to the registry and Joomla asks the provider later:

use Joomla\CMS\Event\Captcha\CaptchaSetupEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;

final class TallpineQuiz extends CMSPlugin implements SubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return ['onCaptchaSetup' => 'setupCaptcha'];
    }

    public function setupCaptcha(CaptchaSetupEvent $event): void
    {
        $event->getCaptchaRegistry()->add(new TallpineQuizProvider($this->params, $this->getApplication()));
    }
}

TallpineQuizProvider here is a class of the plugin that implements Joomla\CMS\Captcha\CaptchaProviderInterface (it reports its name and renders, validates and sets up the field). Editors follow the same shape with onEditorSetup and an EditorProviderInterface, and editor buttons are added with $event->getButtonsRegistry()->add($button) inside onEditorButtonsSetup. The getDisabledButtons() getter lists buttons switched off for the current editor configuration, so a button plugin must respect it. Third-party editors, captchas and buttons written for Joomla 3 and 4 (onInit, onDisplay, onGetContent, and CMSPlugin methods called by JEditor) need rewriting for the registry model. The reCAPTCHA plugin was removed from core in 5.0, which left Joomla with no bot-protection captcha until the Proof-of-Work captcha arrived in 6.1; the registered providers appear in the Default Captcha list of the Global Configuration. Details of the site-wide settings are on Global configuration.

Triggering custom events

Any extension can announce its own events. The Tallpine component wants other extensions, such as a newsletter plugin, to react when a trail is closed. There are four steps.

1. Define the event class. A concrete class documents the arguments and validates them:

<?php

namespace Tallpine\Component\Tallpine\Administrator\Event;

use Joomla\CMS\Event\AbstractImmutableEvent;
use Joomla\CMS\Event\Result\ResultAware;
use Joomla\CMS\Event\Result\ResultAwareInterface;
use Joomla\CMS\Event\Result\ResultTypeBooleanAware;

\defined('_JEXEC') or die;

/**
 * Announced after a trail has been closed.
 * Listeners may add a boolean result: false means "I could not notify my subscribers".
 */
final class TrailClosedEvent extends AbstractImmutableEvent implements ResultAwareInterface
{
    use ResultAware;
    use ResultTypeBooleanAware;

    public function __construct(string $name, array $arguments = [])
    {
        foreach (['trailId', 'reason'] as $required) {
            if (!\array_key_exists($required, $arguments)) {
                throw new \BadMethodCallException("Argument '$required' of event $name is required but has not been provided");
            }
        }

        parent::__construct($name, $arguments);
    }

    protected function onSetTrailId(int $value): int
    {
        return $value;
    }

    public function getTrailId(): int
    {
        return $this->arguments['trailId'];
    }

    public function getReason(): string
    {
        return (string) $this->arguments['reason'];
    }
}

2. Import the plugin group and dispatch. The component’s model or controller derives from the Joomla MVC classes, which implement DispatcherAwareInterface, so getDispatcher() is available. The group is imported against that dispatcher before the event is created:

use Joomla\CMS\Plugin\PluginHelper;
use Tallpine\Component\Tallpine\Administrator\Event\TrailClosedEvent;

// Inside a model method of com_tallpine, after the trail record was stored
$dispatcher = $this->getDispatcher();

PluginHelper::importPlugin('tallpine', null, true, $dispatcher);

$event = new TrailClosedEvent('onTallpineTrailClosed', [
    'trailId' => (int) $table->id,
    'reason'  => (string) $data['closed_reason'],
]);

$dispatcher->dispatch('onTallpineTrailClosed', $event);

$results = $event->getArgument('result', []);

if (\in_array(false, $results, true)) {
    $this->setError('COM_TALLPINE_NOTIFY_FAILED');
}

3. Subscribe in a plugin of the same group. A plugin with group="tallpine" in its manifest reacts:

public static function getSubscribedEvents(): array
{
    return ['onTallpineTrailClosed' => 'notifyMembers'];
}

public function notifyMembers(TrailClosedEvent $event): void
{
    // ... queue an e-mail to members who follow trail $event->getTrailId() ...
    $event->addResult(true);
}

4. Document the event with its name, arguments, results and the version it appeared in, so that other developers can subscribe.

Practical rules:

  • Choose a prefix for the event names (onTallpine...) so that they never collide with core or another extension.

  • importPlugin() takes the dispatcher as its fourth argument. Without it the plugins register to the global application dispatcher, which usually is the right one in a web request but is the wrong one inside code that runs with its own dispatcher.

  • Dispatching a bare Joomla\Event\Event or the generic GenericEvent also works for quick cases, but then every subscriber must use getArgument() and cannot rely on types.

  • Do not announce an event from inside a listener of the same event unless you want recursion.

  • A plugin that dispatches its own events must implement DispatcherAwareInterface itself. From Joomla 7 CMSPlugin no longer provides it.

  • AbstractEvent::create('onXxx', $args) builds the right concrete class from the name when it is a known core event and falls back to GenericEvent; it requires a subject object argument.

Security

  • Escape on output. Content handlers write into HTML. Everything from the database, from the request or from parameters goes through htmlspecialchars() or the HTMLHelper equivalents. Parameters are administrator input but also reach pages.

  • Check the client and the context. A handler on onContentPrepare runs for articles, modules, categories, contacts, third-party components. Return early unless the context is the one you handle.

  • Check permissions in AJAX plugins. onAjax<Name> handlers are reachable by anyone who can build the URL, including guests. Verify the token with Session::checkToken(), and the ACL with $user->authorise().

  • Never put credentials in a plugin file or its default parameters. Tokens and API keys for the park-authority service used by plg_task_tallpine_trailsync belong in parameters filled by an administrator (stored in the database) or in environment variables read at runtime, never in source control.

  • Do not throw away results of untrusted listeners. When you dispatch an event with results, validate their type and contents before using them. The type traits help but cannot judge the meaning.

  • Be conservative with stop() and priority MAX. A plugin that blocks other listeners can disable the security plugins that rely on the same event.

  • Authentication plugins must never log, display or store the submitted password, and must compare credentials with the helpers of the user manager, not hand-written hash code.

  • The plugin runs with the permissions of the web server user. It must not write outside the media and cache folders.

Broader guidance, including the JED Checker rules, is on Security in extensions.

Testing and debugging plugins

A SubscriberInterface plugin is easy to test: instantiate the class, inject doubles through the setters, build the concrete event with an argument array and call the handler directly.

use Joomla\CMS\Event\Content\ContentPrepareEvent;
use Joomla\CMS\Application\CMSApplicationInterface;
use Joomla\Registry\Registry;
use PHPUnit\Framework\TestCase;
use Tallpine\Plugin\Content\TallpineConditions\Extension\TallpineConditions;

final class TallpineConditionsTest extends TestCase
{
    public function testItIgnoresOtherContexts(): void
    {
        $app = $this->createStub(CMSApplicationInterface::class);
        $app->method('isClient')->willReturn(true);

        $plugin = new TallpineConditions(['params' => new Registry()]);
        $plugin->setApplication($app);

        $item  = (object) ['id' => 1, 'text' => 'Trail report'];
        $event = new ContentPrepareEvent('onContentPrepare', [
            'context' => 'com_modules.module',
            'subject' => $item,
            'params'  => new Registry(),
            'page'    => 0,
        ]);

        $plugin->appendConditions($event);

        $this->assertSame('Trail report', $item->text);
    }
}

Test infrastructure is described on Testing extensions and the debug tools on Debugging. Two debugging facts are specific to plugins. With System - Debug enabled the debug bar lists the events that were dispatched and the listeners called, in order, which is the fastest way to check priorities and to see whether a group was imported. And a plugin that is enabled but does not seem to run is nearly always in one of the situations in the next section.

Common errors and pitfalls

Symptom Cause and fix

The plugin is enabled and nothing happens

The group was never imported by the code that announces the event, the event name has a typo (onContentprepare), the handler is not public, or the plugin is not in the access level of the current user (plugin rows have an Access column and only plugins visible to the user’s view levels are loaded).

"Class …​ not found" in provider.php

The <namespace> element is missing or does not match the class, or the autoload map was not rebuilt after copying files by hand. Reinstall from the package or use the System → Maintenance → Clear cache and discover the extension again.

Handler runs with unexpected arguments or null values

getArgument('item') instead of the getter getItem(), or the event is a generic one for which the argument is called subject.

BadMethodCallException: Cannot set the argument ... of the immutable event

Trying to change an argument of a concrete event. Change the object inside the argument, call the specific update method or add a result.

The save is not cancelled by a validation

The handler return`ed `false instead of calling addResult(false), or the event is not result aware.

Deprecation notices about $app and $db in the debug bar

The plugin still declares those properties. Remove them and use getApplication() and getDatabase().

Call to a member function ... on null for the database

The provider forgot setDatabase(), or the call happens in the constructor.

E_USER_DEPRECATED: The plugin should implement SubscriberInterface

The class relies on the legacy listener scan; add the interface and getSubscribedEvents().

Plugin "works on the site but fails in the CLI or the API"

The code used Factory::getApplication()->input or getTemplate(), which exist only for web clients. Check isClient() and use instanceof before calling client-specific methods.

The page cache stores output before the plugin changed it

The handler priority is lower than the cache plugin’s onAfterRender; raise it above Priority::LOW.

Another plugin overwrites your output

Order and priority; adjust the priority, not the ordering column.

The extension will not extend a core plugin

Core plugin classes are final; copy the idea into your own plugin or subscribe to the event.

A plugin in the behaviour group is disabled after the upgrade to 6

That group belongs to compatibility plugins managed by core; move your plugin to another group.

Version notes and corrections of older material

  • Joomla 3.x: plugins are procedural classes PlgContentName extends JPlugin in name.php, methods take positional arguments, $app and $db are protected properties. Joomla 3 is end of life.

  • Joomla 4.0: namespaced plugins, services/provider.php, the event dispatcher of the Joomla Framework, generic event objects, and the legacy listener emulation.

  • Joomla 4.3: $allowLegacyListeners deprecated, with removal planned for 7.0.

  • Joomla 5.0: the concrete event classes (@since 5.0.0 in the source), the setup events for captchas and editors, removal of the reCAPTCHA plugin from core, ES5 assets removed.

  • Joomla 5.4: registerListeners() for plugins not implementing SubscriberInterface is deprecated with removal in 7.0.

  • Joomla 6.0: the legacy Joomla\CMS\Input, CliApplication and other APIs only through the "Behaviour - Backward Compatibility 6" plugin. Console commands are delivered by console group plugins.

  • Joomla 6.1: Proof-of-Work captcha plugin (@since 6.1.0 manifest).

  • Joomla 7.0 (planned): removes the auto-detection of $app and $db plugin properties, the legacy listeners, the dispatcher parameter of the plugin constructor and the dispatcher and language awareness of CMSPlugin. The backward-compatibility policy removes deprecated code in the second major after deprecation and raises minimum requirements only in the first release of a major.

Corrections of older books:

  • Developing Extensions for Joomla! 5 (Packt, 2023) is the strongest Joomla extension book, but its plugin chapters show some Joomla 4-era shapes: a non-namespaced CMSPlugin web services plugin with onBeforeApiRoute(&$router), $db as a plugin property, generic Event::getArgument() access and Factory::getLanguage(). Replace them with SubscriberInterface, the concrete event classes and the Aware traits as shown above. Its coverage of SubscriberInterface plugins is correct and still valid.

  • Joomla! 4 Masterclass (Packt, 2023) targets Joomla 4, which has been end of life since October 2025; it does not cover concrete event classes, the setup events, or the changes of 5.x and 6.x.

  • Pages on the docs.joomla.org wiki about plugins and the plugin events list are legacy and mark themselves obsolete; they describe JPlugin and positional arguments. The Developer Manual replaces them.

Checklist

  • The plugin has services/provider.php, src/Extension/<Name>.php and a manifest with <namespace path="src">.

  • The class is final, extends CMSPlugin, implements SubscriberInterface and defines getSubscribedEvents() with explicit priorities where order matters.

  • Every handler takes the concrete event class and uses its getters; results go through addResult().

  • The application comes from getApplication(), the database from getDatabase() with DatabaseAwareTrait; no $app, $db, Factory::getDbo().

  • The handler returns early for contexts and clients it does not handle.

  • Output is escaped, queries are bound, AJAX endpoints check token and ACL.

  • No secrets in code; parameters have filter or validate rules.

  • The group is the correct one and, for custom groups, the announcing code imports it.

  • A unit test builds the event and calls the handler.