Developers

Requirements

What an extension has to have before a hub will load it. Getting the code onto a hub is a separate question, covered in Deploying extensions.

Nothing on this page is negotiable and none of it is checked for you. There is no validator, no install step that inspects the package and complains. Every requirement here fails at runtime instead, and most of them fail quietly: a directory in the wrong place is a class that does not exist, a missing row is an extension the hub cannot see, an unimported facade is a fatal on one branch. Get the shape right first and the rest of the work is ordinary PHP.

The platform

Hubzero 2.4 requires PHP 8.2 or later (Hubzero\System\Requirements holds the minimum and the list of required PHP extensions) and MariaDB or MySQL. Write extensions against that, not against the PHP 5.4 constraint that most shipped composer.json files still carry — those constraints are stale, and nothing enforces them.

Naming

The name is not decoration. It determines the directory, the entry file, the manifest, the language files, the class namespace and the #__extensions row, and all of them have to agree.

Kind Directory Element name
Component components/com_bookings com_bookings
Module modules/mod_bookings mod_bookings
Plugin plugins/bookings/notify notify, with folder = 'bookings'
Template templates/partner partner

Use lowercase and, where you need a separator, an underscore. A plugin's package directory is conventionally plg_{group}_{name}, but what is installed is plugins/{group}/{name} — the group is where the package lands, not a directory inside it.

The layout

Each kind has its own chapter on structure — components, modules, plugins, templates — but the shape is the same everywhere: an entry point, an XML manifest named after the extension, a composer.json, language files under language/{tag}/, and a migrations/ directory.

A component is the one with more than one face. Laid out for com_bookings, the instrument-booking component the component chapters build:

com_bookings/
    admin/          the administrator interface
        bookings.php
        controllers/  views/  language/en-GB/
    api/            the REST controllers
    site/           what hub visitors see
        bookings.php
        router.php  controllers/  views/  language/en-GB/
    config/config.xml
    migrations/
    models/
    composer.json
    bookings.xml

The entry point

Hubzero\Component\Loader looks, in order, for a Components\Bookings\Site\Bootstrap class, then for site/bookings.php, then for a React application under site/assets/react/, and only then falls back to its default dispatcher. Write the second form: every component in the tree uses it, and it is four lines that hand off to the controller:

<?php
/**
 * @package    hubzero-cms
 * @copyright  Copyright (c) 2005-2020 The Regents of the University of California.
 * @license    http://opensource.org/licenses/MIT MIT
 */

namespace Components\Blog\Site;

require_once dirname(__DIR__) . DS . 'models' . DS . 'archive.php';

$controllerName = \Request::getCmd('controller', \Request::getCmd('view', 'entries'));
if (!file_exists(__DIR__ . DS . 'controllers' . DS . $controllerName . '.php'))
{
	$controllerName = 'entries';
}
require_once __DIR__ . DS . 'controllers' . DS . $controllerName . '.php';
$controllerName = __NAMESPACE__ . '\\Controllers\\' . ucfirst(strtolower($controllerName));

// Instantiate controller

A module's entry point is mod_{name}.php; Hubzero\Module\Loader includes the file. A plugin's entry point is {name}.php, holding a class whose public methods are named for the events it answers — the method name is the registration, so a misspelled event name is a method that is simply never called.

The XML manifest

Every extension carries an XML manifest named after it — bookings.xml — and the manifest is not listed in its own <files> block. The CMS reads the display name, the description and the parameter form from it, and caches it in the extension row's manifest_cache column. Refresh Cache in the Extension Manager re-reads it after an edit.

The manifest is metadata, not an installer script. Nothing in it creates a table, a row or a menu entry; an extension with no manifest at all still runs.

<?xml version="1.0" encoding="utf-8"?>
<extension version="2.5" type="plugin" group="system">
	<name>plg_system_debug</name>
	<creationDate>December 2012</creationDate>
	<author>HUBzero</author>
	<authorUrl>hubzero.org</authorUrl>
	<authorEmail>support@hubzero.org</authorEmail>
	<copyright>Copyright (c) 2005-2020 The Regents of the University of California.</copyright>
	<license>http://opensource.org/licenses/MIT MIT</license>
	<version>2.5.0</version>
	<description>PLG_DEBUG_XML_DESCRIPTION</description>
	<files>
		<filename plugin="debug">debug.php</filename>
		<filename>index.html</filename>
	</files>
	<languages>
		<language tag="en-GB">en-GB.plg_system_debug.ini</language>
Element Meaning
type component, module, plugin or template. Required.
group The plugin group. Plugins only, and required for them.
client site or administrator. Modules and templates.
version The manifest format version, not the extension's.
<name> Shown in the administrator's lists. A language key or a readable string.
<description> A language key, resolved from the .sys.ini file.
<files> What to install. <folder> for a whole directory; the plugin= or module= attribute on a <filename> marks the entry point.
<languages> Translation files.
<config> The parameter form. See Parameters.
<positions> Template positions. Templates only.

The Composer manifest

{
	"name": "myorg/com_bookings",
	"description": "Instrument booking component for the Hubzero CMS",
	"type": "hubzero-component",
	"keywords": ["hubzero"],
	"license": "MIT",
	"require": {
		"php": "^8.2"
	}
}

The recognised types are hubzero-component, hubzero-module, hubzero-plugin and hubzero-template. A plugin must also declare where it installs, because its group cannot be recovered from a name like plg_bookings_notify:

	"extra": {
		"install-directory": "/plugins/bookings/notify/"
	}

Language files

One directory per language tag, one file named for the tag and the extension:

language/en-GB/en-GB.mod_bookings.ini
language/en-GB/en-GB.mod_bookings.sys.ini

A component has one set per client, and the administrator's set is not the site's. See Languages.

The migration

The row in #__extensions is what makes an extension exist as far as the platform is concerned, and a migration is how an extension creates it — the only way, because this release has no package installer to do it for you. The migration also creates the extension's tables and drops them again on the way down.

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');
	}
}

Write this file first, before the controllers. It is one call, it takes a minute, and skipping it produces an extension that appears to work and is absent from every administrative screen.

addComponentEntry is a macro — one of the helpers Base resolves out of Hubzero\Content\Migration\Macros. There is one per kind — addComponentEntry, addModuleEntry, addPluginEntry, addTemplateEntry — with a matching delete... for each, and enable... / disable... alongside. Use them rather than writing the INSERT yourself: they set the columns the loaders read, they create the asset row that permissions hang off and the administrator menu entry, and re-running them is safe. A hand-written INSERT is the older way and gets those side effects wrong.

Note the argument order for a plugin: addPluginEntry($folder, $element) — the group first, then the name. addComponentEntry($name) takes the name first.

index.html

Every directory carries an empty index.html, so that a misconfigured web server cannot list it. Copy the ones already in the tree.

The entry guard

Any file that produces output — a view template, an entry point, anything outside a class — opens with:

defined('_HZEXEC_') or die();

_HZEXEC_ is defined by core/bootstrap/app.php and by nothing else, so the guard stops the file dead if it is requested directly over HTTP. A file that only declares a namespaced class does not need it. See Constants.

Facade imports

The platform's short names — Route, Lang, User, Config, Request, Component — are aliases in the root namespace. An extension class is namespaced, so an unqualified Route::url() inside it resolves to Components\Bookings\Site\Controllers\Route and fatals when that line runs. The file parses; nothing fails until the branch executes — which is why it usually turns up on the error path, in front of a user.

Import every facade you use:

namespace Components\Bookings\Site\Controllers;

use Hubzero\Component\SiteController;
use Request;
use Route;
use Lang;

php tools/lint/missing-facade-imports.php finds the ones you missed, and runs on every push. See Facades.

Which facades exist depends on the client. Toolbar and Submenu are registered only for the administrator, Pathway only for the site, and the API client has none of those, nor Notify, Document or Html. The use statement is valid either way; the class is not there.

Rewritten and checked against 2.4-main @ 348f0057c2 on 2026-09-10.