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.
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 exampletallpine_conditions, and the extension is known in the database asplg_content_tallpine_conditions -
classes live under
src/and are mapped to a namespace declared in the manifest, hereTallpine\Plugin\Content\TallpineConditions; by convention the class that implements the plugin sits in theExtensionsub-namespace -
services/provider.phpreturns a service provider that bindsPluginInterfaceto 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>
Source: Joomla Manual: How plugins work
The details that matter:
-
type="plugin"andgroup="content"decide the folder the files are copied to,plugins/content/tallpine_conditions. The group is a free string: a custom group such astallpineis 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 onefolder(orfilename) element names the plugin element, which is also the folder name underplugins/content. -
<namespace path="src">is what makesTallpine\Plugin\Content\TallpineConditions\...resolve tosrc/.... Without it the autoload map has no entry and theprovider.phpfails 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 theparamscolumn of the extension record and exposed as$this->params(aJoomla\Registry\Registry). Always give fields afilteror avalidaterule; the value reaches the page and must be treated as input. -
The
.sys.inifile 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$configarray is how$this->params,$this->_nameand$this->_typeare 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)).CMSPluginstill accepts it in Joomla 6 but raises a deprecation notice, and the class stops implementingDispatcherAwareInterfacein 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'];
}
}
Source: Joomla Manual: How plugins work
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],
];
}
Source: Joomla Manual: How plugins work
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 |
|---|---|---|
|
3 |
Must run before anything else, for example to short-circuit |
|
2 |
Validation that should run before other handlers |
|
1 |
Slightly early |
|
0 |
Default when only a method name is given |
|
-1 |
Core’s Schema.org plugins use it so that custom types can win |
|
-2 |
Core’s page cache plugin uses it for |
|
-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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Source: Joomla Manual: Plugin events
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')returnsnull, and$event['item']returnsnullwithout a warning, which hides the mistake. -
A getter has a return type.
getContext()returnsstring,getParams()returnsRegistry; the constructor of the event coerces or rejects wrong types.ContentPrepareEventwraps a non-Registryparamsvalue in aRegistryand raises a deprecation, and Joomla 7 will throw. -
The constructor of the event validates required arguments.
ContentEventthrows aBadMethodCallExceptionwhencontextorsubjectis missing, so an extension dispatchingonContentPreparemust supply both. -
AbstractEvent::getArgument()also looks for anonGet<Name>method and, as a deprecated fallback, aget<Name>method. The getter-for-preprocessing fallback is removed in Joomla 7; new event classes writeonGet<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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
set by the base class or |
Notes that avoid the common mistakes:
-
getDatabase()throws or returnsnullif the provider forgotsetDatabase(). CallinggetDatabase()in the constructor is too early; the setters run after construction. -
Do not call
Factory::getDbo(); it is deprecated. Do not callFactory::getUser()orFactory::getLanguage()either; use$this->getApplication()->getIdentity()and->getLanguage(). -
In an event handler, prefer what the event offers.
ApplicationEvent::getApplication()(afinalmethod) returns the application that announced the event, which is the application the event belongs to. -
$this->paramsis aRegistry.$this->params->get('css_class', 'trail-conditions')gives a default when the setting was never saved. -
Language strings load lazily.
protected $autoloadLanguage = true;loadsplg_<group>_<element>on construction (or afteronAfterInitialiseif 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);
}
}
}
Source: Joomla Manual: How plugins work
Points worth noticing:
-
The class is
final, extendsCMSPlugin, implementsSubscriberInterfaceand usesDatabaseAwareTrait: this is the shape of every current core plugin. -
The two handlers have descriptive names and different priorities. The decorator runs after most other
onContentPrepareplugins 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 explicitParameterType, never string concatenation. -
Output that came from the database is escaped with
htmlspecialchars().onContentPrepareoutput goes straight into the page, and a stored string with markup would otherwise run in the visitor’s browser. -
onContentPrepareis also dispatched for modules, category descriptions and other contexts, which is why the first lines return early unless the context iscom_content.article. -
$item->textis the working copy of the article body. Writing tointrotextorfulltexthere 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."
Source: Joomla Manual: How plugins work
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 |
|---|---|---|
|
The application lifecycle, imported on every request |
|
|
Items shown or stored by content components |
|
|
Account and group lifecycle |
|
|
Verifying credentials at login |
|
|
Bot protection providers |
|
|
Text editors |
|
|
Buttons below an editor |
|
|
Custom field types |
|
|
Smart Search content adapters |
|
|
Scheduled task routines |
|
|
Commands for the Joomla CLI |
Commands registered when the console application imports the group; see Console command plugins |
|
Routes of the Web Services API |
|
|
Schema.org types and their form fields |
|
|
Compatibility layers owned by core, for example "Behaviour - Backward Compatibility 6" |
Do not put your own plugins here (see upgrade notes) |
|
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 |
Source: Joomla Manual: Plugin events
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
systemthat does work on every page needs a fast early return. Use acontentorfieldsplugin 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 (
Adapterfor Smart Search,TaskPluginTraitfor tasks), not only an event handler, which is why they have their own pages. -
A custom group is created simply by using a new
groupvalue, as the Tallpine example below does withtallpine. 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, classJoomla\CMS\Event\Captcha\CaptchaSetupEvent, gettergetCaptchaRegistry()returning aCaptchaRegistry -
onEditorSetup, classJoomla\CMS\Event\Editor\EditorSetupEvent, gettergetEditorsRegistry()returning anEditorsRegistryInterface -
onEditorButtonsSetup, classJoomla\CMS\Event\Editor\EditorButtonsSetupEvent, gettersgetButtonsRegistry(),getEditorType(),getDisabledButtons(),getEditorId(),getAsset()andgetAuthor()
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()));
}
}
Source: Joomla Manual: Plugin events
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);
}
Source: Joomla Manual: How plugins work
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\Eventor the genericGenericEventalso works for quick cases, but then every subscriber must usegetArgument()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
DispatcherAwareInterfaceitself. From Joomla 7CMSPluginno 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 toGenericEvent; it requires asubjectobject argument.
Security
-
Escape on output. Content handlers write into HTML. Everything from the database, from the request or from parameters goes through
htmlspecialchars()or theHTMLHelperequivalents. Parameters are administrator input but also reach pages. -
Check the client and the context. A handler on
onContentPrepareruns 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 withSession::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_trailsyncbelong 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 priorityMAX. 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);
}
}
Source: Joomla Manual: How plugins work
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 ( |
"Class … not found" in |
The |
Handler runs with unexpected arguments or |
|
|
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 |
Deprecation notices about |
The plugin still declares those properties. Remove them and use |
|
The provider forgot |
|
The class relies on the legacy listener scan; add the interface and |
Plugin "works on the site but fails in the CLI or the API" |
The code used |
The page cache stores output before the plugin changed it |
The handler priority is lower than the cache plugin’s |
Another plugin overwrites your output |
Order and priority; adjust the priority, not the |
The extension will not extend a core plugin |
Core plugin classes are |
A plugin in the |
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 JPlugininname.php, methods take positional arguments,$appand$dbare 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:
$allowLegacyListenersdeprecated, with removal planned for 7.0. -
Joomla 5.0: the concrete event classes (
@since 5.0.0in 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 implementingSubscriberInterfaceis deprecated with removal in 7.0. -
Joomla 6.0: the legacy
Joomla\CMS\Input,CliApplicationand other APIs only through the "Behaviour - Backward Compatibility 6" plugin. Console commands are delivered byconsolegroup plugins. -
Joomla 6.1: Proof-of-Work captcha plugin (
@since 6.1.0manifest). -
Joomla 7.0 (planned): removes the auto-detection of
$appand$dbplugin properties, the legacy listeners, the dispatcher parameter of the plugin constructor and the dispatcher and language awareness ofCMSPlugin. 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
CMSPluginweb services plugin withonBeforeApiRoute(&$router),$dbas a plugin property, genericEvent::getArgument()access andFactory::getLanguage(). Replace them withSubscriberInterface, the concrete event classes and the Aware traits as shown above. Its coverage ofSubscriberInterfaceplugins 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
JPluginand positional arguments. The Developer Manual replaces them.
Checklist
-
The plugin has
services/provider.php,src/Extension/<Name>.phpand a manifest with<namespace path="src">. -
The class is
final, extendsCMSPlugin, implementsSubscriberInterfaceand definesgetSubscribedEvents()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 fromgetDatabase()withDatabaseAwareTrait; 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
filterorvalidaterules. -
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.