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.