Developers
Migrations
Copying a template into place is not enough. The CMS only knows about a
template that has a row in #__extensions and a style in #__template_styles.
A migration puts those rows there.
Why this exists
Nothing scans the filesystem for templates. The administrator's template list
is a query against #__extensions, and
Hubzero\Template\Loader
resolves a request to a directory by joining #__template_styles to that same
table. A directory with no rows behind it is invisible: it does not appear in
the administrator, it cannot be picked by a menu item, and ?templateStyle=
cannot reach it.
The migration is how those rows get written on every hub that installs your template, in a form that can be reversed. Writing them by hand with SQL works once, on your own machine, and is forgotten the first time the template is deployed anywhere else.
Every extension type can carry a migrations directory, and templates are no
exception:
app/templates/northgate/
css/
html/
img/
js/
language/
migrations/
Migration20260910102219TplNorthgate.php
component.php
error.php
index.php
templateDetails.xml
template_thumbnail.png
favicon.ico
Hubzero\Content\Migration
scans every directory under core/templates and app/templates for a
migrations subdirectory, alongside the component, module and plugin trees.
The smallest one that works
A template usually needs one migration, and it usually does one thing: call
addTemplateEntry(). This is the whole of northgate's:
<?php
/**
* Migration script for the Northgate site template
*/
use Hubzero\Content\Migration\Base;
// No direct access
defined('_HZEXEC_') or die();
class Migration20260910102219TplNorthgate extends Base
{
public function up()
{
// element, title, client (0 = site), enabled, home
$this->addTemplateEntry('northgate', 'Northgate University', 0, 1, 0);
}
public function down()
{
$this->deleteTemplateEntry('northgate', 0);
}
}
Run it:
php core/bin/muse migration -f -e=tpl_northgate
The template now appears under Extensions → Templates and can be made the
site's default style. Compare the shipped kimera one, which differs only in
its arguments:
<?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 entry for Template - Kimera plugin
**/
class Migration20170831000000TplKimera extends Base
{
/**
* Up
**/
public function up()
{
$this->addTemplateEntry('kimera', 'kimera', 0, 1, 1, null, 1);
}
/**
* Down
**/
public function down()
{
$this->deleteTemplateEntry('kimera', 0);
}
}
up() registers the template; down() removes it again. That is the whole
job. The macro writes the #__extensions row and, if #__template_styles
exists, the style row too.
addTemplateEntry
$this->addTemplateEntry($element, $name, $client, $enabled, $home, $styles, $protected);
| Argument | Default | Meaning |
|---|---|---|
$element |
— | The template's directory name. A leading tpl_ is stripped. |
$name |
null |
The style title shown in the admin. Defaults to a title-cased $element. |
$client |
1 |
0 for the site, 1 for the administrator. |
$enabled |
1 |
Whether the extension row is enabled. |
$home |
0 |
Whether this becomes the client's default style. Setting it clears home on every other style for that client. |
$styles |
null |
An array of style parameters, JSON-encoded into the style row. |
$protected |
0 |
Intended to mark the template as a core one. |
Note the default client is the administrator. A site template must pass
0 explicitly. This is the mistake to expect:
// Wrong: registers a site template as an administrator style
$this->addTemplateEntry('northgate', 'Northgate University');
It fails quietly. The rows are written, the migration reports success, and the
template never appears in the site template list — because
getTemplate() filters on client_id in both tables. Filter the administrator
template list by Administrator and there it is, alongside kameleon.
installTemplateEntry() is the same macro with $enabled and $home both
forced to 1:
$this->installTemplateEntry($element, $name, $client, $styles, $protected);
Use it only when the template genuinely should take over the hub the moment it
is installed. northgate uses addTemplateEntry with $home = 0 so that
someone chooses the switch-over.
deleteTemplateEntry
$this->deleteTemplateEntry($element, $client);
$client defaults to 1, so a site template's down() must pass 0 — the
same trap in reverse, and this one leaves the rows behind instead of removing
them. The macro removes the extension row and every style row for that
template, then — if it has just removed the client's default style — promotes
the most recently added remaining style to home so the client is never left
without one.
When the template does not appear
Four things go wrong here, in rough order of frequency.
The migration never ran. muse migration without -f lists what is
pending and changes nothing. Run it with -f.
The file name and the class name disagree. The runner takes the class name
from the file name and skips the file with a warning —
Migration20260910102219TplNorthgate does not have a class of the same name —
rather than failing. A template copied from kimera keeps
Migration20170831000000TplKimera.php and its class, so the file matches
itself and runs kimera's registration a second time, or fatals on the
duplicate class name if the shipped kimera is still installed. Rename both.
The list is cached. Loader::getTemplate() caches the whole template list
per client under com_templates.templates{client_id} for the hub's cachetime
— fifteen minutes by default. A freshly registered style can take that long to
become selectable. Clear the cache rather than re-running the migration.
The row exists and the directory does not. These are independent. The
install data ships #__extensions rows for hubbasic, hubbasic2012,
hubbasic2013 and hubbasicadmin, none of which are directories in this
repository, and they are listed in the administrator all the same. A style
whose directory has no index.php silently falls back to core/templates/system
at render time. So a name in the template list is not evidence that a template
is there.
Other macros
The macros live in
core/libraries/Hubzero/Content/Migration/Macros
and are reached through __call() on the migration base class, so any file in
that directory is callable as $this->methodName().
Rewritten and checked against 2.4-main @ 91d03d0a23 on 2026-09-10.