Developers
Migrations
A component's migrations directory holds the code that installs it: the row
in #__extensions that makes the hub aware of it, the tables it needs, and any
seed data. Later migrations in the same directory carry the schema forward as
the component changes.
This page covers what a component's migrations do. The mechanics — naming, the runner, logging, hooks, and the full macro list — are in Migrations under Database.
Why this comes first
The migration is the installer. There is no other one. No package
installer reads an XML manifest and creates anything from it; the
Install, Update, Discover and Database screens do not exist in
this release (see
Deploying extensions). Code arrives as a
directory, by git or by hand, and then a migration is run against it.
That makes the first migration you write the one that decides whether the hub can see the component at all. Its failure mode is quiet and confusing:
/index.php?option=com_bookingsrenders. The component works.- Components in the administrator menu does not list it.
Component::params('com_bookings')returns an empty registry, so every setting silently falls back to whatever second argument you passedget()— or tonullif you passed none.User::authorise('core.manage', 'com_bookings')has no asset row to consult.
The component runs because
Hubzero\Component\Loader::load()
manufactures a default record with enabled set to 1 when the query finds no
row. Everything stored against the row is what you lose.
Where they go
app/components/com_bookings/
migrations/
Migration20260901000000ComBookings.php register the component
Migration20260901000100ComBookings.php create the tables
Migration20260901000200ComBookings.php seed the instruments
The file name is Migration + a fourteen-digit timestamp + the component name
in studly case with com_ expanded to Com. The class name matches the file
name exactly. Files run in sorted order, so the timestamp decides the
sequence.
The runner finds them without being told. On construction
Hubzero\Content\Migration
scans core/ and app/ and every directory under their components,
modules, templates and plugins trees, and adds any that has a
migrations subdirectory. A component dropped into app/components/ is
picked up on the next run.
Three migrations, not one
Convention splits installation into three files: register the component,
create the tables, seed the data. Nothing enforces the split — one migration
could do all three — but keeping them apart lets you re-run a piece without
re-running the rest, and makes each down() obvious.
Registering the component
addComponentEntry() writes the #__extensions row, creates the asset row
that permissions hang off, and adds the administrator menu entry. That one
call is the whole of the first migration. com_kb's is the pattern to copy:
use Hubzero\Content\Migration\Base;
// No direct access
defined('_HZEXEC_') or die();
/**
* Migration script for adding component entry for com_kb
**/
class Migration20170831000000ComKb extends Base
{
/**
* Up
**/
public function up()
{
$this->addComponentEntry('kb');
}
/**
* Down
**/
public function down()
{
$this->deleteComponentEntry('kb');
}
}
com_bookings is the same file with the name changed:
use Hubzero\Content\Migration\Base;
defined('_HZEXEC_') or die();
class Migration20260901000000ComBookings extends Base
{
public function up()
{
$this->addComponentEntry('bookings');
}
public function down()
{
$this->deleteComponentEntry('bookings');
}
}
The com_ prefix is optional; addComponentEntry('bookings') and
addComponentEntry('com_bookings') do the same thing. The full signature is
addComponentEntry($name, $option = null, $enabled = 1, $params = '', $createMenuItem = true),
so pass false as the fifth argument for a component that should not appear in
the administrator's Components menu.
deleteComponentEntry() in down() removes the extension row, the asset row,
and the menu entry.
The macros are the way to do this. Writing the INSERT yourself is the older
route, and it goes wrong in ways that are hard to see: a missing asset row, a
menu entry with no place in the nested set, a second row when you re-run.
addComponentEntry() sets everything the loaders read and is safe to run
twice. The same is true of addModuleEntry(), addPluginEntry() and
addTemplateEntry() for the other kinds.
Creating the tables
$this->db is a full database driver. Guard every statement so the migration
can run against a hub that already has the table:
if (!$this->db->tableExists('#__kb_articles'))
{
$query = "CREATE TABLE `#__kb_articles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(250) DEFAULT NULL,
`alias` varchar(200) DEFAULT NULL,
`params` text,
`fulltxt` text,
`created` datetime DEFAULT NULL,
`created_by` int(11) DEFAULT '0',
`modified` datetime DEFAULT NULL,
`modified_by` int(11) DEFAULT '0',
`checked_out` int(11) DEFAULT '0',
`checked_out_time` datetime DEFAULT NULL,
`state` int(3) DEFAULT '0',
`access` tinyint(3) DEFAULT '0',
`hits` int(11) DEFAULT '0',
`version` int(11) DEFAULT '0',
`category` int(11) DEFAULT '0',
`helpful` int(11) NOT NULL DEFAULT '0',
`nothelpful` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_category` (`category`),
KEY `idx_alias` (`alias`),
FULLTEXT KEY `ftidx_title` (`title`),
FULLTEXT KEY `ftidx_title_params_fulltxt` (`title`,`params`,`fulltxt`),
FULLTEXT KEY `ftidx_params` (`params`),
FULLTEXT KEY `ftidx_fulltxt` (`fulltxt`),
FULLTEXT KEY `ftidx_title_fulltxt` (`title`,`fulltxt`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;";
$this->db->setQuery($query);
$this->db->query();
}
com_kb repeats that block for #__kb_comments and #__kb_votes, and its
down() drops all three. Table names use the #__ prefix placeholder, which
the driver expands to the hub's configured prefix. Writing jos_ or hub_
into a migration hard-codes one hub's prefix into every other hub — see
the prefix.
Name tables after the component: #__bookings_instruments,
#__bookings_reservations. The ORM depends on it — a Relational model with
protected $namespace = 'bookings' and the class name Reservation resolves
to #__bookings_reservations without being told. Get the plural wrong in the
migration and the model looks for a table that is not there. See
Models.
Seeding data
A data migration checks whether its rows are already there before inserting, the same way a table migration checks for its table:
public function up()
{
if (!$this->db->tableExists('#__bookings_instruments'))
{
return;
}
$this->db->setQuery(
"SELECT `id` FROM `#__bookings_instruments` WHERE `alias`='confocal'"
);
if ($this->db->loadResult())
{
return;
}
$now = with(new \Hubzero\Utility\Date('now'))->toSql();
$this->db->setQuery(
"INSERT INTO `#__bookings_instruments`
(`title`, `alias`, `created`, `created_by`, `state`, `access`)
VALUES ('Confocal microscope', 'confocal', "
. $this->db->quote($now) . ", 1000, 1, 1)"
);
$this->db->query();
}
down() deletes the same rows by the same criteria.
Seed only what the component cannot work without. Sample content that a hub then has to delete is worse than none.
Changing an existing schema
Everything after the install migrations is maintenance, and it follows the
same rule: check first, then change. com_kb's third migration converts its
DATETIME columns from NOT NULL DEFAULT '0000-00-00 00:00:00' to nullable,
and checks both the table and the column before touching either:
class Migration20190221000000ComKb extends Base
{
/**
* List of tables and their datetime fields
*
* @var array
**/
public static $tables = array(
'#__kb_articles' => array(
'created',
'modified',
'checked_out_time'
),
'#__kb_comments' => array(
'created'
)
);
/**
* Up
**/
public function up()
{
foreach (self::$tables as $table => $fields)
{
foreach ($fields as $field)
{
if ($this->db->tableExists($table)
&& $this->db->tableHasField($table, $field))
{
$query = "ALTER TABLE `$table` CHANGE `$field` `$field` DATETIME NULL DEFAULT NULL";
$this->db->setQuery($query);
$this->db->query();
$query = "UPDATE `$table` SET `$field`=NULL WHERE `$field`='0000-00-00 00:00:00'";
$this->db->setQuery($query);
$this->db->query();
}
}
}
}
The three checks worth knowing are tableExists(), tableHasField(), and
tableHasKey().
Never edit a migration that has run anywhere. #__migrations records it as
done, so the edit will never be applied on the hubs that already have it, and
the two hubs drift apart with nothing to show for it. Add another migration
instead.
Running them
php core/bin/muse migration # dry run: what would happen
php core/bin/muse migration -f # actually run it
php core/bin/muse migration -f -e=com_bookings # only this component
php core/bin/muse migration -f -d=down -e=com_bookings # roll it back
Run the dry form first. It prints the files it would execute, which is also the quickest way to find out that it can see none of yours.
Every migration that runs is recorded in #__migrations, so a second run is a
no-op. That is a record, not a guarantee: a hub restored from a backup or an
extension copied between hubs can present a migration with a schema it did not
expect, which is why the existence checks matter.
Rewritten and checked against 2.4-main @ 348f0057c2 on 2026-09-10.