Developers
Migrations
A module has to be registered in the #__extensions table before the CMS will
list it or let an administrator create an instance of it. That registration is
done by a migration: a small PHP class in the module's migrations directory
that the muse migration command runs.
Write it first. A module is the one extension kind that cannot be published at all without its row — a component with no row still serves its own pages, a plugin at least fails at a known event, but an unregistered module simply is not in the Module Manager's list of things you can create. How an extension is found sets the four kinds side by side.
Where migrations live
app/modules/mod_upcoming_bookings/
migrations/
Migration20260101000000ModUpcomingBookings.php
The class name carries the timestamp and the extension, and the file is named after the class. Migrations are run in timestamp order across every extension on the hub, so a module that depends on a component's tables should carry a later timestamp than the migration that creates them.
The registration migration
Most modules need exactly one migration, and it is four lines long:
<?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;
/**
* Migration script for installing mygroups module
**/
class Migration20190109000000ModMyGroups extends Base
{
/**
* Up
**/
public function up()
{
$this->addModuleEntry('mod_mygroups');
}
/**
* Down
**/
public function down()
{
$this->deleteModuleEntry('mod_mygroups');
}
}
mod_upcoming_bookings is the same file with its own name in it:
class Migration20260101000000ModUpcomingBookings extends Base
{
public function up()
{
$this->addModuleEntry('mod_upcoming_bookings');
}
public function down()
{
$this->deleteModuleEntry('mod_upcoming_bookings');
}
}
That is the whole of it, because the module owns no tables. It reads
#__bookings_reservations, which com_bookings's own migration creates — so
give the module a timestamp later than the component's, and declare the
dependency in composer.json as well. See Packaging.
addModuleEntry() and deleteModuleEntry() are macros resolved by
Hubzero\Content\Migration\Base
from the Macros directory beside it. Their signatures are:
| Macro | Signature |
|---|---|
addModuleEntry |
($element, $enabled = 1, $params = '', $client = 0) |
deleteModuleEntry |
($element, $client = null) |
enableModule |
($element) |
disableModule |
($element) |
installModule |
($module, $position, $always = true, $params = '', $client = 0, $menus = 0) |
saveParams |
($element, $params) |
getParams |
($element, $returnRaw = false) |
$element is the full directory name, including the mod_ prefix. $client
is 0 for a site module and 1 for an administrator module, and must agree
with client="site" or client="administrator" in the XML manifest, or the
module will never appear in the client you built it for.
Creating an instance from a migration
Registering a module does not place it anywhere. If your module is meant to
appear as soon as it is installed, installModule() writes a published
#__modules instance assigned to a position, and a #__modules_menu row for
each menu id in $menus — the default of 0 meaning all of them.
One migration in the tree does this, and it is worth reading whole because it gets both hard parts right:
public function up()
{
$this->deletePluginEntry('content', 'collect');
$this->deletePluginEntry('resources', 'collect');
$this->deletePluginEntry('wiki', 'collect');
$this->addModuleEntry('mod_collect', 1, '', 0);
$query = "SELECT COUNT(*) FROM `#__modules` WHERE `module`='mod_collect'";
$this->db->setQuery($query);
if (!$this->db->loadResult())
{
$position = 'endpage';
$found = false;
$query = "SELECT COUNT(*) FROM `#__modules` WHERE `client_id`=0 AND `position`=";
$this->db->setQuery($query . $this->db->quote($position));
if ($this->db->loadResult())
{
$found = true;
}
if (!$found)
{
$position = 'footer';
$this->db->setQuery($query . $this->db->quote($position));
if ($this->db->loadResult())
{
$found = true;
}
}
if ($found)
{
$this->installModule('collect', $position);
}
}
}
addModuleEntry() first, installModule() second, and the position is not
guessed: the migration looks for a position other site modules are already
using — endpage, then footer — and places the instance only if it finds
one. A position no template renders would have left mod_collect published
and invisible.
$always does not mean what the name suggests either. It does not control the
menu assignment: $menus does that, in every case. $always = false makes the
macro skip the insert when a #__modules instance of that module already
exists, so a re-run does not add a second copy. $always = true, the default,
inserts every time.
Most modules should not use installModule() at all. Placing a block is the
hub's decision — not every hub wants mod_upcoming_bookings, and those that
do will not agree on which column — so register it with addModuleEntry() and
let an administrator place it. Reach for installModule() only when the
module is useless anywhere else, and then find the position the way
mod_collect does rather than naming one and hoping. See
Loading.
Tables and schema
Most modules need none. A module that renders somebody else's records — the
booking module reads #__bookings_reservations and writes nothing — has
nothing to create, and a module that finds itself wanting a table of its own
is usually a component in disguise.
Where a module really does own data, it creates the tables in the same migration, using the raw query helpers described in the database migrations chapter:
public function up()
{
if (!$this->db->tableExists('#__example_items'))
{
$this->db->query("CREATE TABLE `#__example_items` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;");
}
$this->addModuleEntry('mod_example');
}
Write the matching down() so the change can be rolled back. Dropping a table
in down() throws away data, so most core modules only reverse the extension
registration and leave the schema alone.
Running them
muse migration # dry run: report what would change
muse migration -f # apply
muse migration -f -i # apply, including migrations dated before the last run
Until the migration has been applied, Hubzero\Module\Loader::all() will not
return the module — its query joins #__modules to #__extensions and
requires e.enabled = 1 — so an unregistered module renders nothing even if
every file is in place.
What that looks like: New in the Module Manager does not list
mod_upcoming_bookings at all. There is no error and no log line; the module
is simply not among the things you can create. If it is missing from that
list, the migration has not run.
Rewritten and checked against 2.4-main @ 91d03d0a23 on 2026-09-10.