Developers
Migrations
A plugin does nothing until it has a row in #__extensions. Dropping the
files into core/plugins or app/plugins is not enough:
Hubzero\Plugin\Loader::all()
builds its list from that table, not from the filesystem, so an unregistered
plugin is never required, never constructed, and never bound to an event.
This is the first chapter of the section because it is the first thing to write. A plugin with no migration behaves exactly like a plugin whose event never fires and exactly like a plugin with a misspelled method name — nothing happens, and nothing says why. Write the migration, run it, confirm the plugin appears in the Plugin Manager, and only then start debugging anything else.
Registration is done by a migration in the plugin's own migrations
directory, run by muse migration.
Where migrations live
app/plugins/bookings/notify/
migrations/
Migration20260210000000PlgBookingsNotify.php
The class name is Migration{timestamp}Plg{Group}{Name}, and the file is
named after the class. Migrations run in timestamp order across every
extension on the hub, so give a plugin that depends on a component's tables a
later timestamp than the migration that creates them — plg_bookings_notify
after com_bookings, not before.
The registration migration
For plg_bookings_notify, the whole migration is two calls:
<?php
use Hubzero\Content\Migration\Base;
// No direct access
defined('_HZEXEC_') or die();
class Migration20260210000000PlgBookingsNotify extends Base
{
public function up()
{
$this->addPluginEntry('bookings', 'notify');
}
public function down()
{
$this->deletePluginEntry('bookings', 'notify');
}
}
Which is, allowing for the names, the shipped members blog migration:
<?php
/**
* @package hubzero-cms
* @copyright Copyright (c) 2005-2020 The Regents of the University of California.
* @license http://opensource.org/licenses/MIT MIT
*/
use Hubzero\Content\Migration\Base;
// No direct access
defined('_HZEXEC_') or die();
/**
* Migration script for adding Members - Blog plugin
**/
class Migration20170831000000PlgMembersBlog extends Base
{
/**
* Up
**/
public function up()
{
$this->addPluginEntry('members', 'blog');
}
/**
* Down
**/
public function down()
{
$this->deletePluginEntry('members', 'blog');
}
}
addPluginEntry() and deletePluginEntry() are macros resolved by
Hubzero\Content\Migration\Base.
The plugin-related ones are:
| Macro | Signature |
|---|---|
addPluginEntry |
($folder, $element, $enabled = 1, $params = '') |
deletePluginEntry |
($folder, $element = null) |
enablePlugin |
($folder, $element) |
disablePlugin |
($folder, $element) |
renamePluginEntry |
($folder, $element, $name) |
savePluginParams |
($folder, $element, $params) |
$folder is the group directory — bookings, content, system — and
$element is the plugin's own directory name. Neither carries a prefix; the
plg_bookings_notify form is assembled where it is needed.
AddPluginEntry
lower-cases both before writing them, so the row always holds
folder = 'bookings', whatever case you passed.
The row is written with enabled as given, access 1 (Public), state 0,
and client_id 0. addPluginEntry() first checks for an existing row with
that folder and element and returns early if it finds one, so re-running a
migration is safe — and so a second addPluginEntry() cannot be used to
change a plugin that is already installed. Use enablePlugin(),
disablePlugin() or savePluginParams() for that.
Shipping default parameters
addPluginEntry() takes a fourth argument, stored in the row's params
column. Pass a JSON string, or an array, which the macro encodes for you. Use
it when a plugin must arrive with a value other than the manifest default:
public function up()
{
$this->addPluginEntry('content', 'formatwiki', 1, '{"applyFormat":"0","convertFormat":"1"}');
}
For a plugin that is already installed, savePluginParams() writes the
params column instead. It replaces the column outright — it does not
merge — so read the current values first and set only what you are changing.
That is the right tool when a later migration adds a field:
public function up()
{
$params = $this->getParams('plg_content_formatwiki');
$params->set('convertFormat', 0);
$this->savePluginParams('content', 'formatwiki', $params);
}
getParams() returns a Hubzero\Config\Registry unless you pass true as
its second argument, in which case you get the raw JSON string.
savePluginParams() accepts a Registry, an array, or nothing usable — the
third case logs a warning and returns false rather than throwing.
Installing disabled
Pass 0 as the third argument to register a plugin without switching it on.
This is the polite default for anything that changes site behaviour on sight,
or that cannot work until it is configured — an authentication provider, a
content filter, or plg_bookings_notify, which has no manager address to mail
until an administrator types one in:
$this->addPluginEntry('bookings', 'notify', 0);
The plugin then appears in the Plugin Manager, disabled, which is the difference between "waiting for you" and the silence of no row at all.
Ordering
Plugins in a group run in the order given by the ordering column, ascending;
Loader::all() sorts on it and the dispatcher preserves insertion order for
listeners of equal priority.
addPluginEntry() sets the ordering itself: it takes the highest ordering
already present in the same folder and adds one, so a newly registered plugin
runs last in its group. That is the right default. If your plugin must run
before a specific sibling — a content filter that has to see raw text, for
instance — update the column explicitly in the migration and say why in a
comment, because nothing else in the tree records the dependency.
Tables
Create any tables the plugin needs in the same migration, and write the
matching down():
public function up()
{
if (!$this->db->tableExists('#__bookings_notifications'))
{
$this->db->query("CREATE TABLE `#__bookings_notifications` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`reservation_id` int(11) NOT NULL DEFAULT 0,
`sent` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;");
}
$this->addPluginEntry('bookings', 'notify', 0);
}
Write #__ for the table prefix, never a literal jos_; see
Database. Most core plugins reverse only
the registration in down() and leave the schema alone, because dropping a
table throws away data that a re-run cannot restore.
Running them
muse migration # dry run
muse migration -f # apply
muse migration -f -i # apply, including migrations dated before the last run
-e=plg_bookings_notify limits a run to one extension, which is what you want
while iterating.
If the migration runs and the plugin still does not appear, the row went in
under a folder or element you did not expect: check #__extensions directly
before assuming the loader is at fault.
Rewritten and checked against 2.4-main @ 91d03d0a23 on 2026-09-10.