Developers
Structure
A plugin is identified by two names: the group it belongs to and its own. Get
them right once and everything else follows from them, because both are
directory names and together they determine the class name, the language file
name, the asset paths, the view override path, and the folder and element
columns of the plugin's row in #__extensions. Get one of them wrong and the
plugin does not load, with no message anywhere. That is what this chapter is
about.
For the worked example the two names are bookings and notify, so the
plugin is plg_bookings_notify and it lives in
app/plugins/bookings/notify/.
Directory layout
Plugins are never at the top level of plugins; they are always one level
down, inside a group directory:
app/plugins/
{group}/
{name}/
{name}.php
{name}.xml
So plg_bookings_notify:
app/plugins/bookings/notify/
language/en-GB/en-GB.plg_bookings_notify.ini
language/en-GB/en-GB.plg_bookings_notify.sys.ini
migrations/Migration20260210000000PlgBookingsNotify.php
views/email/tmpl/message.php
composer.json
index.html
notify.php
notify.xml
Only notify.php and notify.xml are required, and the migration if the
plugin is to run at all. Because the group is part of the path, the same
plugin name can be reused in different groups — core/plugins/content/formatwiki
and a wiki group plugin of the same name would coexist without conflict.
Group and directory must match
The group name is not a label. It is a path segment, and it comes from the
database:
Hubzero\Plugin\Loader::path()
takes the folder column of the extension row, joins it to plugins, and
looks for that directory. The same value is the prefix on the events the
plugin hears, because Event::trigger('bookings.onReservationCreate', …)
splits at the dot and imports the group named on the left.
Nothing validates the pair. There is no register of legal group names, no manifest check, and no warning when the directory named by a row does not exist.
So when com_bookings fires bookings.onReservationCreate, the plugin
directory is plugins/bookings/, the migration calls
addPluginEntry('bookings', 'notify'), and the manifest says
group="bookings". Singular or plural is your choice; making the same choice
in all four places is not.
Characters in the two names
Hubzero\Plugin\Loader strips anything outside letters, digits, _, ., and
- from both names before building a class name or a path. That is what is
permitted. What is safe is narrower: lowercase letters and digits, and
nothing else.
Where the loader looks
Loader::path() resolves a group and name to a directory by checking
PATH_APP first and PATH_CORE second:
app/plugins/{group}/{name}core/plugins/{group}/{name}
The entry file is {name}.php inside whichever directory exists. A hub
overrides a shipped plugin by copying the whole directory into app/plugins;
the extension row does not change, because it records only the group and the
element. Your own plugins belong in app/, always —
core/ is replaced wholesale by an upgrade.
The class
The entry file must define a class the loader can find. Two names are accepted, checked in this order:
| Form | Example |
|---|---|
plg{Group}{Name} |
plgBookingsNotify, plgMembersBlog, plgContentFormatwiki |
Plugins\{Group}\{Name} |
Plugins\Bookings\Notify |
The loader concatenates the two column values without changing their case and
calls class_exists() on the result, and PHP resolves class names
case-insensitively, so the capitalisation in your file is not what makes the
match. Follow the convention anyway: only the first letter of each name is
capitalised, so formatwiki gives plgContentFormatwiki and not
plgContentFormatWiki, and every plugin shipped in core/plugins is written
that way.
Every shipped plugin uses the first form, in the global namespace. The
namespaced form is supported by the loader but unused; supporting classes
under a plugin — models, helpers, macros — do use Plugins\Group\Name\…
namespaces, and are loaded by the framework's class loader.
<?php
// No direct access
defined('_HZEXEC_') or die();
class plgBookingsNotify extends \Hubzero\Plugin\Plugin
{
public function onReservationCreate($reservation)
{
// ...
}
}
Facades in a plugin
The plugin entry class is declared in the root namespace, so an
unqualified Lang::txt(), Route::url() or Config::get() in it already
names the alias. No use line is needed, and none of the shipped plugins has
one. This is the one place in the tree where the import rule does not bite,
and it is why plugin files look different from component files.
Everything else beside the plugin does declare a namespace — models, helpers,
a Plugins\Bookings\Notify\Helpers\Recipients class — and there the rule
applies in full. It is subtler than "it fatals": an unimported facade in a
namespaced file usually works, because the alias autoloader falls back to
the last segment of the class name, and then fails later in a handful of
specific situations. Read Facades
for which ones, and run
php tools/lint/missing-facade-imports.php app/plugins/bookings/notify before
you ship.
The entry guard
Plugin files begin with
defined('_HZEXEC_') or die();
before any executable code. Plugin directories are inside the document root on a default install, so this line is the only thing preventing a direct HTTP request from running the file outside the application. It belongs at the top of the entry file, the migration, and every view template. See Constants.
Subdirectories
Everything else is optional and follows fixed names, because the framework
looks for them by convention: views/{name}/tmpl/{layout}.php for
views, assets/{css,js,img} for assets,
language/{tag} for languages, migrations for
migrations. Models and helpers have no enforced location;
helpers beside the entry file is the common arrangement, and is where a
plugin view looks first when a layout calls a method the view does not have.
Rewritten and checked against 2.4-main @ 91d03d0a23 on 2026-09-10.