Module Development
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
A module is the small, positioned block of output on a Joomla page: a menu, a login form, a list of the latest articles, a banner. It is the extension type that site builders meet most often, and the one a developer can ship in an afternoon, because a module has no routing, no administrator interface of its own and no model-view-controller structure. It reads some data, passes it to a layout and prints HTML.
This page was written against Joomla 6.2.x on PHP 8.3 or later and checked on 2026-10-30. It builds the
mod_tallpine_nexthikes module of the Tallpine Hiking Club, the fictitious mountain club whose site is developed
across this sub-section: a site module that lists the next scheduled group hikes, lets a visitor refresh the number of
free places through AJAX, and is shipped inside the pkg_tallpine package. The structure described here is the one used
by the core modules of 4.2 and later (the Dispatcher and Helper design), and it is unchanged in 6.x. The Manual’s
step-by-step tutorial is organised for Joomla 6.1, and the source of the classes quoted below was read on the 6.1
development branch, which 6.2 follows for this area.
The page covers the file layout and manifest, the dependency-injection provider, the Dispatcher, the Helper and its
database access, layouts and template overrides, parameters, language files, assets, caching, AJAX through com_ajax,
security, testing hooks and the common mistakes. Namespaces, autoloading and the container in general are explained in
Namespaces, Autoloading and Dependency
Injection; the extension types and the packaging of pkg_tallpine are in
Extension Types, Manifests and Packaging; the site
builder’s view of positions and module settings is in Modules and Positions.
The PHP language features used here (namespaces, interfaces, traits, anonymous classes, readonly and typed properties)
are covered in PHP; this page does not re-teach them.
What a module is and how Joomla renders it
A module is an installed extension of type="module" with a client of site or administrator. The module
extension itself is installed once; what an editor creates under Content > Site Modules is a module instance, a
row of the #__modules table with a title, a position, a status, menu assignments, an access level, a language and its
own parameters. One extension can have many instances on the same site, for example a mod_tallpine_nexthikes instance
showing three hikes in the sidebar and another showing ten in the footer of the German pages. The extension row lives in
#__extensions, and the mapping from instances to menu items is in #__modules_menu.
When a page is rendered, a template calls <jdoc:include type="modules" name="sidebar-right" style="card" />. For each
published instance in that position whose menu assignment, access level and language match, Joomla runs this chain:
Three facts from that chain matter for everything below.
-
The module is booted through the container: Joomla loads the extension’s
services/provider.php, which registers a dispatcher factory and a helper factory. There is nomod_name.phpentry file in a modern module. -
The Dispatcher decides what data the layout receives, and the Helper is where the data comes from. The Dispatcher never runs SQL itself.
-
The chrome (the
styleof the position, such ascardornone) wraps the layout’s output after the module has rendered. An instance whose layout prints nothing produces an emptycontent, and thecardchrome of Cassiopeia then prints nothing at all, so an empty module leaves no empty box on the page. The chrome and layout system is the subject of Layouts and Module Chromes.
The files of mod_tallpine_nexthikes
mod_tallpine_nexthikes/
├── mod_tallpine_nexthikes.xml
├── services/
│ └── provider.php
├── src/
│ ├── Dispatcher/
│ │ └── Dispatcher.php
│ └── Helper/
│ └── TallpineNexthikesHelper.php
├── tmpl/
│ ├── default.php
│ └── compact.php
├── language/
│ └── en-GB/
│ ├── mod_tallpine_nexthikes.ini
│ └── mod_tallpine_nexthikes.sys.ini
└── media/
├── joomla.asset.json
└── js/
└── nexthikes.js
On installation the folders services, src and tmpl are copied to modules/mod_tallpine_nexthikes/, the files of
media to media/mod_tallpine_nexthikes/, and the language files to
language/en-GB/. Everything except the manifest sits in folders; nothing is loaded from the archive root. Directory
and class names are case sensitive: they work on a laptop with a case-insensitive file system and then fail on a Linux
server, which is the most common reason for a module that "worked in development".
The module’s type prefix mod_ is part of the element name. A site module is always installed under modules/, an
administrator module under administrator/modules/.
The manifest
The manifest is the XML file at the root of the archive. It tells the installer what to copy, where, and what the module’s parameters are.
<?xml version="1.0" encoding="UTF-8"?>
<extension type="module" client="site" method="upgrade">
<name>mod_tallpine_nexthikes</name>
<author>Tallpine Hiking Club</author>
<creationDate>2026-10</creationDate>
<copyright>(C) 2026 Tallpine Hiking Club</copyright>
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
<authorEmail>web@tallpine.example</authorEmail>
<authorUrl>https://tallpine.example</authorUrl>
<version>1.0.0</version>
<description>MOD_TALLPINE_NEXTHIKES_XML_DESCRIPTION</description>
<namespace path="src">Tallpine\Module\NextHikes</namespace>
<files>
<folder module="mod_tallpine_nexthikes">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/mod_tallpine_nexthikes.ini</language>
<language tag="en-GB">language/en-GB/mod_tallpine_nexthikes.sys.ini</language>
</languages>
<media destination="mod_tallpine_nexthikes" folder="media">
<filename>joomla.asset.json</filename>
<folder>js</folder>
</media>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="count"
type="number"
label="MOD_TALLPINE_NEXTHIKES_FIELD_COUNT_LABEL"
description="MOD_TALLPINE_NEXTHIKES_FIELD_COUNT_DESC"
default="5"
min="1"
max="20"
filter="integer"
validate="number"
/>
<field
name="difficulty"
type="list"
label="MOD_TALLPINE_NEXTHIKES_FIELD_DIFFICULTY_LABEL"
default=""
validate="options"
>
<option value="">JALL</option>
<option value="easy">MOD_TALLPINE_NEXTHIKES_DIFFICULTY_EASY</option>
<option value="moderate">MOD_TALLPINE_NEXTHIKES_DIFFICULTY_MODERATE</option>
<option value="hard">MOD_TALLPINE_NEXTHIKES_DIFFICULTY_HARD</option>
</field>
<field
name="show_spots"
type="radio"
label="MOD_TALLPINE_NEXTHIKES_FIELD_SHOW_SPOTS_LABEL"
layout="joomla.form.field.radio.switcher"
default="1"
filter="integer"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="hide_when_empty"
type="radio"
label="MOD_TALLPINE_NEXTHIKES_FIELD_HIDE_EMPTY_LABEL"
layout="joomla.form.field.radio.switcher"
default="0"
filter="integer"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
</fieldset>
<fieldset name="advanced">
<field
name="layout"
type="modulelayout"
label="JFIELD_ALT_LAYOUT_LABEL"
class="form-select"
validate="moduleLayout"
/>
<field
name="moduleclass_sfx"
type="textarea"
label="COM_MODULES_FIELD_MODULECLASS_SFX_LABEL"
rows="3"
validate="CssIdentifier"
/>
<field
name="cache"
type="list"
label="COM_MODULES_FIELD_CACHING_LABEL"
default="1"
filter="integer"
validate="options"
>
<option value="1">JGLOBAL_USE_GLOBAL</option>
<option value="0">COM_MODULES_FIELD_VALUE_NOCACHING</option>
</field>
<field
name="cache_time"
type="number"
label="COM_MODULES_FIELD_CACHE_TIME_LABEL"
default="900"
filter="integer"
/>
<field
name="cachemode"
type="hidden"
default="static"
>
<option value="static"></option>
</field>
</fieldset>
</fields>
</config>
</extension>
The elements that matter, and what goes wrong with each:
method="upgrade"-
Installing a newer version over an existing one replaces the files instead of failing with "module already exists". Without it the second install of the same extension is refused.
<namespace path="src">-
Maps the PSR-4 prefix
Tallpine\Module\NextHikesto thesrcfolder. Joomla builds its extension class map from this element when the extension is installed, so a change to the namespace needs the extension to be reinstalled or discovered again, not just a file edit. The namespace must match the string passed to the factories inservices/provider.php. <folder module="mod_tallpine_nexthikes">services</folder>-
The
moduleattribute names the module and is what the installer reads to register the extension element. It sits on theservicesfolder in current modules; the older convention put it on<filename module="mod_x">mod_x.php</filename>. Forgetting to listservicesis a classic: the install succeeds and the module renders nothing, because the container has no provider. <languages>-
Lists the language files copied to
language/en-GB/, one.inifor the front end and one.sys.inifor the installer, the Module Manager and the manifest description. See Language files. <media>-
Copies assets to
media/mod_tallpine_nexthikes/. Thedestinationis relative tomedia/, andfolderis the source folder inside the archive. <config><fields name="params">-
Declares the form shown in the module’s edit screen and the parameters stored in the instance’s
paramscolumn. The fieldset namedbasicbecomes the first tab,advancedthe second. Fields ofname="params"are stored as JSON under the field names.
The three advanced fields shown above (layout, moduleclass_sfx, cache, cache_time, cachemode) are exactly those
of the core modules, and they are what makes the Alternative Layout, Module Class and Caching controls appear.
validate="moduleLayout" makes Joomla reject a layout name that does not exist for the module, and
validate="CssIdentifier" rejects characters that could break out of an HTML attribute, a point that returns in
Security checklist for a module.
Manifest syntax in general (the <update> and <updateservers> sections, <scriptfile>, <install> SQL) is in
Extension Types, Manifests and Packaging and
Installer Scripts and SQL Updates.
The service provider
services/provider.php is the module’s composition root. Joomla includes the file when it boots the extension and
expects it to return an object that implements ServiceProviderInterface.
<?php
/**
* @package Tallpine.Site
* @subpackage mod_tallpine_nexthikes
*
* @copyright (C) 2026 Tallpine Hiking Club
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Tallpine\\Module\\NextHikes'));
$container->registerServiceProvider(new HelperFactory('\\Tallpine\\Module\\NextHikes\\Site\\Helper'));
$container->registerServiceProvider(new Module());
}
};
Each of the three lines registers a ready-made provider from the core:
-
ModuleDispatcherFactory(the one inJoomla\CMS\Extension\Service\Provider) registers a dispatcher factory that receives the root namespace. When a dispatcher is needed, the factory builds the class name<root namespace>\Site\Dispatcher\Dispatcherfor the front end or<root namespace>\Administrator\Dispatcher\Dispatcherfor the administrator, and falls back to the genericModuleDispatcherof the core when that class does not exist. TheSitesegment belongs to the PHP namespace only. The files stay directly undersrc/Dispatcher/andsrc/Helper/, as in the tree above: the core modules ship exactly this arrangement (for examplemodules/mod_articles_latest/src/Dispatcher/Dispatcher.phpdeclares the namespaceJoomla\Module\ArticlesLatest\Site\Dispatcher, with the manifest rootJoomla\Module\ArticlesLatest). When in doubt, open a core module of the installed Joomla and copy its layout. -
HelperFactoryreceives the helper namespace and instantiates helpers by short class name. -
Moduleregisters the extension object, the thing returned byFactory::getApplication()->bootModule().
Two pitfalls are specific to this file.
-
Two classes named ModuleDispatcherFactory.
Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactoryis the service provider used above.Joomla\CMS\Dispatcher\ModuleDispatcherFactoryis the factory it registers. A wrongusestatement produces a confusing error aboutregister()orcreateDispatcher()not being found. -
Backslashes in strings. The namespace strings need doubled backslashes in single quotes, and the leading backslash is optional because the factories trim it.
The provider can register anything else a module needs. Because the container is a PSR-11 container, a helper or a dispatcher can be given custom services by registering them here, and a plugin can replace container entries (useful for substituting a fake in tests; see Testing a module).
The Dispatcher
The Dispatcher is the controller of a module. In Joomla 4.2 and later it extends AbstractModuleDispatcher, which
implements dispatch() and leaves one method to override, getLayoutData().
<?php
namespace Tallpine\Module\NextHikes\Site\Dispatcher;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
\defined('_JEXEC') or die;
/**
* Dispatcher for mod_tallpine_nexthikes.
*/
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
{
use HelperFactoryAwareTrait;
/**
* Collects the variables the layout receives.
*
* @return array|false The layout data, or false to render nothing.
*/
protected function getLayoutData()
{
$data = parent::getLayoutData();
$hikes = $this->getHelperFactory()
->getHelper('TallpineNexthikesHelper')
->getHikes($data['params'], $this->getApplication());
if (!$hikes && (int) $data['params']->get('hide_when_empty', 0) === 1) {
return false;
}
$data['hikes'] = $hikes;
return $data;
}
}
What AbstractModuleDispatcher::dispatch() does, read from the 6.1 source:
-
calls
loadLanguage(), which loads the module’s language file from the application base or frommodules/<module name>/unless it is already loaded; -
calls
getLayoutData(); -
stops silently if that returned
false; -
otherwise extracts the returned array into local variables and includes the file chosen by
ModuleHelper::getLayoutPath($module->module, $params->get('layout', 'default')), inside a closure, so the layout cannot see the Dispatcher’s own variables or$this.
The base getLayoutData() returns module (the stdClass row of the instance), app, input, params (a Registry
of the instance’s parameters) and template (the name of the active template). Every key you add to the array becomes a
variable of the same name in the layout: $data['hikes'] becomes $hikes.
HelperFactoryAwareInterface and HelperFactoryAwareTrait are what let the Dispatcher ask for helpers. When the
extension object builds the Dispatcher in Module::getDispatcher(), it calls setHelperFactory() on it, but only when
the Dispatcher implements the interface. Leave out the interface or the trait and getHelperFactory() throws an
UnexpectedValueException ("HelperFactory not set"), which is the typical failure of a module copied from a 3.x or an
early 4.0 example.
getApplication() is available on the Dispatcher through the base class. In the layout, the application is
$app, the same object.
Returning false is the supported way to say "this instance has nothing to show". The module then outputs no HTML at
all, so the chrome is not drawn. Returning an array whose layout prints nothing has the same visible result on
Cassiopeia, but it does more work and a chrome that does not test for empty content would still print its frame.
You can also override dispatch() for modules that must not use a layout at all, for example one that sets headers, but
for HTML output prefer getLayoutData(): it keeps the layout override mechanism intact.
The older static design
Older modules, and books written before Joomla 4.2, use three other files: an entry file mod_name.php that does
require_once __DIR__ . '/helper.php'; $list = ModNameHelper::getList($params); require ModuleHelper::getLayoutPath(...),
a helper.php with a static helper class, and JLoader::register(). Developing Extensions for Joomla! 5 (2023)
already teaches the Dispatcher with HelperFactory, and that is the design to copy. Material that shows the entry file
still works through the legacy path, but core modules have deprecated their static helpers: the static
ArticlesLatestHelper::getList() of mod_articles_latest is marked deprecated since 4.3, to be removed in 7.0, in
favour of the non-static method reached through the booted module. A new module has no reason to start with the old
shape.
The Helper
The Helper is a plain class that answers the Dispatcher’s questions. It is instantiated by the HelperFactory using the
namespace given in services/provider.php, which also means helper methods must not be static.
<?php
namespace Tallpine\Module\NextHikes\Site\Helper;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
\defined('_JEXEC') or die;
/**
* Reads the upcoming hikes. The class name follows the com_ajax convention
* (see the AJAX section): Tallpine + Nexthikes + Helper.
*/
class TallpineNexthikesHelper implements DatabaseAwareInterface
{
use DatabaseAwareTrait;
/**
* @param Registry $params Module parameters.
* @param SiteApplication $app The site application.
*
* @return \stdClass[]
*/
public function getHikes(Registry $params, SiteApplication $app): array
{
$db = $this->getDatabase();
$user = $app->getIdentity();
$now = (new Date('now'))->toSql();
$limit = max(1, min(20, (int) $params->get('count', 5)));
$levels = $user->getAuthorisedViewLevels();
$query = $db->createQuery()
->select(
[
$db->quoteName('h.id'),
$db->quoteName('h.trail_id'),
$db->quoteName('h.title'),
$db->quoteName('h.start_date'),
$db->quoteName('h.difficulty'),
$db->quoteName('h.spots_total'),
$db->quoteName('h.spots_taken'),
]
)
->from($db->quoteName('#__tallpine_hikes', 'h'))
->where($db->quoteName('h.state') . ' = 1')
->where($db->quoteName('h.start_date') . ' >= :now')
->bind(':now', $now)
->whereIn($db->quoteName('h.access'), $levels)
->order($db->quoteName('h.start_date') . ' ASC')
->setLimit($limit);
$difficulty = (string) $params->get('difficulty', '');
if (\in_array($difficulty, ['easy', 'moderate', 'hard'], true)) {
$query->where($db->quoteName('h.difficulty') . ' = :difficulty')
->bind(':difficulty', $difficulty);
}
if ($app->getLanguageFilter()) {
$query->whereIn(
$db->quoteName('h.language'),
[$app->getLanguage()->getTag(), '*'],
ParameterType::STRING
);
}
$hikes = $db->setQuery($query)->loadObjectList();
foreach ($hikes as $hike) {
$hike->link = Route::_('index.php?option=com_tallpine&view=trail&id=' . (int) $hike->trail_id);
}
return $hikes;
}
}
The module reads a table created by com_tallpine (#__tallpine_hikes), which is why this example needs the component
installed from the same pkg_tallpine package. The statement that creates it belongs to the component’s install script:
CREATE TABLE IF NOT EXISTS `#__tallpine_hikes` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`trail_id` int unsigned NOT NULL,
`title` varchar(255) NOT NULL,
`start_date` datetime NOT NULL,
`difficulty` varchar(16) NOT NULL DEFAULT 'moderate',
`spots_total` smallint unsigned NOT NULL DEFAULT 12,
`spots_taken` smallint unsigned NOT NULL DEFAULT 0,
`state` tinyint NOT NULL DEFAULT 1,
`access` int unsigned NOT NULL DEFAULT 1,
`language` char(7) NOT NULL DEFAULT '*',
PRIMARY KEY (`id`),
KEY `idx_start` (`state`, `start_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;
How the database gets into the Helper
The HelperFactory class uses DatabaseAwareTrait itself. When it builds a helper it checks
$helper instanceof DatabaseAwareInterface and, if so, calls setDatabase() with its own database. The provider
HelperFactory registered in services/provider.php is given the database by the container. All the helper has to do is
implement DatabaseAwareInterface and use DatabaseAwareTrait, and $this->getDatabase() returns the
DatabaseInterface connection. That is the same pattern core’s ArticlesLatestHelper uses.
This replaces the calls older books and tutorials use:
-
Factory::getDbo()andFactory::getContainer()->get('DatabaseDriver')inside the helper. Both still resolve, but they reach for a global, make the helper impossible to test without the full application, andFactory::getDbo()is deprecated in favour of the container. -
$db->getQuery(true), deprecated in favour of$db->createQuery(). -
A
$dbproperty assigned by magic: plugins used to get$appand$dbset by the plugin loader, and 7.0 removes that auto-detection. Helpers never had it; they must declare their dependencies through the interfaces.
The application object is passed into the method (getHikes(..., $this->getApplication())) rather than fetched from
Factory::getApplication() inside the helper, so the method has no hidden input and a unit test can pass a mock.
Writing queries that are safe and portable
-
Bind every value.
bind(':now', $now)sends the value as a parameter. A value interpolated into the SQL string with concatenation orquote()of unvalidated input is the classic SQL injection. Bind targets must be variables, not literals, becausebind()takes the value by reference. -
Use
whereIn()for lists with the third argument saying the type (ParameterType::STRINGfor language tags; integers are the default). It builds the placeholder list for you. -
Quote identifiers with
quoteName(), which also converts the#__table prefix. -
Keep the logic in one place. The module filters by access level in the query because a list of hikes the visitor may not see must never reach the layout. Filtering in the layout would leak titles into the cache, the page source and the AJAX response.
-
Joomla supports MySQL, MariaDB and PostgreSQL. The query builder abstracts quoting and
LIMIT, but it does not make arbitrary SQL portable, and install SQL needs one file per family. Avoid vendor-specific functions in the module.
Database techniques beyond this module (transactions, the DatabaseQuery API, fetching modes, drivers) are in
Database API.
Throwing errors from a Helper
Joomla 5.4 and 6 expect exceptions rather than the legacy setError() of CMSObject. A helper that cannot do its job
throws, and the code that called it decides whether to catch. In a module that is almost always the Dispatcher’s choice:
let an unexpected \RuntimeException surface in debug mode, or catch it and return false from getLayoutData() to hide
the module. See Error Handling and Legacy APIs.
Language files
The manifest points at two files. mod_tallpine_nexthikes.sys.ini is read by the installer, the Module Manager and the
type selector, so it holds the description and the module’s name. The plain .ini file is read by loadLanguage() when
the module renders, and by the edit form, so it holds the field labels and the front-end strings.
; mod_tallpine_nexthikes.sys.ini
MOD_TALLPINE_NEXTHIKES="Tallpine: Next hikes"
MOD_TALLPINE_NEXTHIKES_XML_DESCRIPTION="Lists the next scheduled group hikes of the Tallpine Hiking Club."
; mod_tallpine_nexthikes.ini
MOD_TALLPINE_NEXTHIKES_FIELD_COUNT_LABEL="Number of hikes"
MOD_TALLPINE_NEXTHIKES_FIELD_COUNT_DESC="How many upcoming hikes to list (1 to 20)."
MOD_TALLPINE_NEXTHIKES_FIELD_DIFFICULTY_LABEL="Difficulty"
MOD_TALLPINE_NEXTHIKES_DIFFICULTY_EASY="Easy"
MOD_TALLPINE_NEXTHIKES_DIFFICULTY_MODERATE="Moderate"
MOD_TALLPINE_NEXTHIKES_DIFFICULTY_HARD="Hard"
MOD_TALLPINE_NEXTHIKES_FIELD_SHOW_SPOTS_LABEL="Show free places"
MOD_TALLPINE_NEXTHIKES_FIELD_HIDE_EMPTY_LABEL="Hide when there are no hikes"
MOD_TALLPINE_NEXTHIKES_NONE="No hikes are scheduled."
MOD_TALLPINE_NEXTHIKES_SPOTS_LEFT="%d places left"
MOD_TALLPINE_NEXTHIKES_FULL="Full"
MOD_TALLPINE_NEXTHIKES_REFRESH="Refresh places"
MOD_TALLPINE_NEXTHIKES_ERROR_AJAX="The number of places could not be loaded."
Constants are upper case, prefixed with the extension name so they cannot collide, and values are in double quotes.
A %d or %s placeholder is filled with Text::sprintf(). The German translation of the club site is a second pair
of files in language/de-DE/. Language file syntax, overrides and the language-pack workflow are covered in
Language Files and Internationalisation and
Multilingual Sites.
Layouts in tmpl/
A layout is a PHP file in tmpl/ that receives the variables the Dispatcher returned. default.php is used unless the
instance selects another layout in its Alternative Layout field or the template overrides it.
<?php
/**
* @package Tallpine.Site
* @subpackage mod_tallpine_nexthikes
*
* @copyright (C) 2026 Tallpine Hiking Club
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
/**
* @var \stdClass $module The module instance.
* @var \Joomla\Registry\Registry $params The module parameters.
* @var \Joomla\CMS\Application\SiteApplication $app
* @var \stdClass[] $hikes The upcoming hikes.
*/
$wa = $app->getDocument()->getWebAssetManager();
$wa->getRegistry()->addExtensionRegistryFile('mod_tallpine_nexthikes');
$wa->useScript('mod_tallpine_nexthikes.nexthikes');
Text::script('MOD_TALLPINE_NEXTHIKES_ERROR_AJAX');
$showSpots = (int) $params->get('show_spots', 1) === 1;
?>
<div class="mod-tallpine-nexthikes" data-module="<?php echo (int) $module->id; ?>">
<?php if (!$hikes) : ?>
<p class="mod-tallpine-nexthikes__empty"><?php echo Text::_('MOD_TALLPINE_NEXTHIKES_NONE'); ?></p>
<?php else : ?>
<ul class="mod-tallpine-nexthikes__list list-unstyled">
<?php foreach ($hikes as $hike) : ?>
<?php $left = max(0, (int) $hike->spots_total - (int) $hike->spots_taken); ?>
<li class="mod-tallpine-nexthikes__item">
<a href="<?php echo htmlspecialchars($hike->link, ENT_QUOTES, 'UTF-8'); ?>">
<?php echo htmlspecialchars($hike->title, ENT_QUOTES, 'UTF-8'); ?>
</a>
<time datetime="<?php echo HTMLHelper::_('date', $hike->start_date, 'c'); ?>">
<?php echo HTMLHelper::_('date', $hike->start_date, Text::_('DATE_FORMAT_LC4')); ?>
</time>
<?php if ($showSpots) : ?>
<span class="badge bg-secondary" data-hike="<?php echo (int) $hike->id; ?>">
<?php echo $left > 0 ? Text::sprintf('MOD_TALLPINE_NEXTHIKES_SPOTS_LEFT', $left) : Text::_('MOD_TALLPINE_NEXTHIKES_FULL'); ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($showSpots) : ?>
<button type="button" class="btn btn-sm btn-outline-secondary mod-tallpine-nexthikes__refresh">
<?php echo Text::_('MOD_TALLPINE_NEXTHIKES_REFRESH'); ?>
</button>
<?php endif; ?>
<?php endif; ?>
</div>
Points that deserve emphasis:
-
Escape everything that came from data.
htmlspecialchars($value, ENT_QUOTES, 'UTF-8')for text and attribute values, casts for numbers.Text::_()returns the translator’s string and is safe for strings that live in your own language files; a user-editable string needs escaping too. This is the most important security rule of a layout. -
Layouts are presentation. No SQL, no
Factorycalls for data, no business decisions. The Dispatcher and Helper already prepared everything; the layout may read$appfor the document and web asset manager. -
Assets are registered by the layout, not discovered. The Web Asset Manager does not read a module’s
joomla.asset.jsonby itself. The layout callsaddExtensionRegistryFile('mod_tallpine_nexthikes')and thenuseScript(). The Manual notes the call could live in the Dispatcher, and keeps it in the layout by convention. The registry files of the core are loaded in a fixed order (media/vendor,media/system,media/legacy, then component and template ones), and an extension file must be registered before its assets are requested. The mechanics are in Web Asset Manager. -
The first lines are the defined guard. Since Joomla 6.0 the constant
JPATH_PLATFORMis no longer defined (it is kept only through the Backward Compatibility 6 plugin), so older guards such asdefined('JPATH_PLATFORM') or die;stop the file. The guard that works on every supported version is\defined('_JEXEC') or die;. -
Use the Joomla date helper (
HTMLHelper::_('date', ...)) so the stored UTC date is shown in the visitor’s or the site’s time zone, and thecformat for the machine-readable attribute.
Alternative layouts
A second file in tmpl/ is an alternative layout. The Alternative Layout drop-down of the module (field type
modulelayout) lists every tmpl/*.php file of the module, plus layouts supplied by the template. A compact variant for
the footer needs only a different template; the Dispatcher and the Helper are reused:
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
/** @var \stdClass[] $hikes */
?>
<p class="mod-tallpine-nexthikes mod-tallpine-nexthikes--compact">
<?php foreach ($hikes as $hike) : ?>
<span><?php echo htmlspecialchars($hike->title, ENT_QUOTES, 'UTF-8'); ?>
(<?php echo HTMLHelper::_('date', $hike->start_date, 'j M'); ?>)</span>
<?php endforeach; ?>
</p>
Selecting compact stores "layout": "_:compact" in the instance parameters. The value has the form
template:filename, and _ means "the layout in the module itself or the current template’s override". An alternative
layout can also be created by copying a layout into a template, as the next section shows.
Template overrides
ModuleHelper::getLayoutPath() decides which file is included. Read from the 6.1 source, the order for a layout named
default is:
-
templates/<active template>/html/mod_tallpine_nexthikes/default.php; -
when the active template is a child template,
templates/<parent template>/html/mod_tallpine_nexthikes/default.php; -
modules/mod_tallpine_nexthikes/tmpl/default.php; -
modules/mod_tallpine_nexthikes/tmpl/default.phpagain as the final fallback if a path fails the safety check.
The Tallpine template cassiopeia_tallpine, a child of Cassiopeia, can therefore restyle the module without touching the
extension, by placing a file at templates/cassiopeia_tallpine/html/mod_tallpine_nexthikes/default.php. The override
survives module updates, which is why a template must never edit files under modules/. Overrides are best created
from System > Site Template Overrides > Create Overrides, which copies the current file and records its version so the
administrator sees when the original changed. The workflow is described in
Template Overrides and Alternative Layouts, and child
templates in Child Templates.
A module can also define its own overridable sub-layouts through the layout files of the core (LayoutHelper::render()
with layouts/ paths and FileLayout, searched in the template’s html/layouts folder first). That is the right tool
for markup reused by several extensions, and is described in
Layouts and Module Chromes.
Parameters
Parameters have three jobs: they render a form, they are validated and filtered on save, and they travel to the layout
as a Registry named $params (the Dispatcher builds it with new Registry($this->module->params)).
-
Declare filters.
filter="integer"cleans the stored value,validate="number"rejects non-numeric input, andvalidate="options"ensures a list value is one of the declared options. A field with neither stores whatever was posted by an administrator. -
Re-validate when reading. The Helper clamps
countto 1 to 20 and accepts only known difficulty values, because the database row could hold anything if it was changed by SQL, an import or a different Joomla version. -
Use
showonto hide a field depending on another:showon="show_spots:1". -
Default values come from the manifest only when the form is first created. An existing instance keeps its saved JSON, so a parameter added in version 1.1 is absent on old instances. Always read with a default:
$params->get('count', 5). -
Parameters are per instance. Global defaults for a module do not exist; if several instances need a shared setting, put it in a component’s options or in a plugin.
Field types that need data (for example a list of trails) can be supplied by custom field classes in the module’s
namespace, declared with addfieldprefix="Tallpine\Module\NextHikes\Site\Field" on the <fieldset>. Forms and custom
fields are described in Forms and Form Fields.
Assets: JavaScript and CSS
The module’s script is a plain ES module, and the Web Asset Manager receives it through joomla.asset.json:
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "mod_tallpine_nexthikes",
"version": "1.0.0",
"description": "Assets of mod_tallpine_nexthikes",
"license": "GPL-2.0-or-later",
"assets": [
{
"name": "mod_tallpine_nexthikes.nexthikes",
"description": "Refreshes the free places through com_ajax",
"type": "script",
"uri": "mod_tallpine_nexthikes/nexthikes.js",
"attributes": {
"type": "module",
"defer": true
},
"dependencies": ["core", "messages"]
}
]
}
Because the manifest copies the js folder to media/mod_tallpine_nexthikes/js/, the uri is relative to that
media folder’s js directory and omits the js segment, as the Manual’s tutorial shows. Bump the version
whenever a script or stylesheet changes, so browsers do not serve a cached copy. Joomla 6.1 also accepts
"version": "nocache" for development. Dependencies are listed by asset name: core loads Joomla.request and
Joomla.Text, messages loads Joomla.renderMessages. Never load libraries from a CDN in a module and never inline
scripts that include data: Text::script() passes translated strings and Joomla.getOptions() passes data.
Caching
A module can be cached in two different ways, and they should not be confused.
Per-module output caching
The cache, cache_time and cachemode fields from the manifest are what the ModuleRenderer reads. The decision, from the 6.1 source, is:
-
the instance parameter
cacheis1("Use Global") and the Global Configuration cache setting is on (System > Global Configuration > System > Cache, set to Conservative or Progressive, which the code tests as a value of at least 1); -
the
cachemodeis neitheridnorsafeuri(those two are for modules that manage their own cache by callingModuleHelper::moduleCache()from within the module); -
then the renderer wraps the whole
ModuleHelper::renderModule()call in the cache controller of groupmod_tallpine_nexthikes.
Then ModuleHelper::moduleCache() switches caching off for every logged-in user ($user->id is non-zero), when the
global cache is off, or when the instance has cache set to 0. The lifetime in the module is in seconds, and Joomla
converts it for its minute-based cache API. With cachemode static one entry serves every page that renders the same
module parameters; the default branch of the code (itemid) varies the cache by view levels and the current menu item.
Because members who are logged in bypass the cache, the member-only trail conditions of the Trail Warden group
cannot leak from one visitor to another through it, but data that changes by guest language or by query string would:
for such a module use a cache mode that includes the variable, or turn the cache off.
Consequences for mod_tallpine_nexthikes:
-
The hikes list is the same for every guest of a language, so
staticis correct. The module’scache_timeof 900 seconds means a newly published hike appears within fifteen minutes for guests and immediately for logged-in members. -
Anything that must be fresh (a free-places counter, a CSRF token embedded in a form, a per-user greeting) must not be rendered inside a cached module. That is the reason the free places are refreshed by AJAX rather than printed once.
-
Setting a parameter to a different value changes the instance’s cache identity, so editing the module invalidates the entry. A purge of System > Clear Cache clears all module groups.
-
The cache is only as good as the cache handler. File caching is the default; Redis, Memcached or APCu are configured in Global Configuration. The broader strategy is covered in Caching and Performance.
Caching data inside the Helper
A module that calls a slow external service can cache just that call with the cache controller factory of the container,
CacheControllerFactoryInterface, and the callback controller, which is what ModuleHelper itself uses for the module
list. Registering it in services/provider.php and passing it to the Helper keeps the Helper testable. Do not cache
per-user data under a key that lacks the user or the view levels.
AJAX through com_ajax
com_ajax is a core component that exposes a module’s helper methods (and plugins, and templates) as small HTTP
endpoints, so a module does not need a component of its own for a bit of JSON. The request has the form
GET /index.php?option=com_ajax&module=tallpine_nexthikes&method=spots&format=json&hike=7 HTTP/1.1
Host: tallpine.example
Accept: application/json
and it works like this in the 6.1 source:
-
formatis mandatory. Without it com_ajax answers with an error. -
The module is the part of the element after
mod_:module=tallpine_nexthikes. Joomla checks that the extension exists, is enabled and, because the application loads modules through the menu, that it is available for the current request. If not, it returns a 404 "module not accessible" error. -
The module is booted with
$app->bootModule(). If the extension object is aHelperFactoryInterface, com_ajax asks it for the helper nameducfirstof every part of the module name, joined, plusHelper: fortallpine_nexthikesthat isTallpineNexthikesHelper. This is the reason for the helper’s class name above. A helper calledNextHikesHelperworks for rendering (the Dispatcher asks for it by name) but is invisible to com_ajax. -
com_ajax calls the method
<method>Ajax()on the helper (method=spotscallsspotsAjax()), with no arguments. The default method name isget, sogetAjax(). A method that does not end inAjaxcan never be called this way. -
The return value goes through
JsonResponsewhenformat=json, so an array becomes{"success":true,...,"data":...}, and a thrown exception becomes{"success":false,"message":...}.
/**
* Remaining places of one hike. Called by com_ajax as method=spots.
*
* @return array{id: int, left: int, label: string}
*
* @throws \RuntimeException When the hike does not exist or is not visible.
*/
public function spotsAjax(): array
{
$app = Factory::getApplication();
$hikeId = $app->getInput()->getInt('hike', 0);
$levels = $app->getIdentity()->getAuthorisedViewLevels();
$db = $this->getDatabase();
$query = $db->createQuery()
->select([$db->quoteName('spots_total'), $db->quoteName('spots_taken')])
->from($db->quoteName('#__tallpine_hikes'))
->where($db->quoteName('id') . ' = :id')
->where($db->quoteName('state') . ' = 1')
->bind(':id', $hikeId, ParameterType::INTEGER)
->whereIn($db->quoteName('access'), $levels);
$row = $db->setQuery($query)->loadObject();
if (!$row) {
throw new \RuntimeException(Text::_('JERROR_LAYOUT_PAGE_NOT_FOUND'), 404);
}
$left = max(0, (int) $row->spots_total - (int) $row->spots_taken);
return [
'id' => $hikeId,
'left' => $left,
'label' => $left > 0 ? Text::sprintf('MOD_TALLPINE_NEXTHIKES_SPOTS_LEFT', $left) : Text::_('MOD_TALLPINE_NEXTHIKES_FULL'),
];
}
The browser side, in media/js/nexthikes.js:
document.querySelectorAll('.mod-tallpine-nexthikes').forEach((box) => {
const button = box.querySelector('.mod-tallpine-nexthikes__refresh');
if (!button) {
return;
}
button.addEventListener('click', () => {
box.querySelectorAll('[data-hike]').forEach((badge) => {
const url = `index.php?option=com_ajax&module=tallpine_nexthikes&method=spots&format=json&hike=${encodeURIComponent(badge.dataset.hike)}`;
Joomla.request({
url,
method: 'GET',
onSuccess: (response) => {
const result = JSON.parse(response);
if (result.success) {
badge.textContent = result.data.label;
} else {
Joomla.renderMessages({ error: [result.message] });
}
},
onError: () => {
Joomla.renderMessages({ error: [Joomla.Text._('MOD_TALLPINE_NEXTHIKES_ERROR_AJAX')] });
},
});
});
});
});
com_ajax and security
-
com_ajax endpoints are public. Any visitor, including guests and bots, can call
spotsAjax(). The helper must check authorisation itself. The tutorial’s example throws a "not authorised" exception when the user is a guest; the hikes example instead filters by the caller’s view levels, which gives a guest only public hikes. -
Never change data on a GET. A method that writes must require a POST and a form token, with
Session::checkToken()(or$this->checkToken()in a controller) at the start of the method, and the browser side sends the token field (Joomla.getOptions('csrf.token')) as a parameter. A booking button added later would belong incom_tallpine, not in an Ajax helper. -
Validate input with the typed getters of the input (
getInt,getCmd,getWord), asspotsAjax()does. Do not place raw request values into SQL, file paths or HTML. -
Avoid information leaks in exceptions. The message of a thrown exception is returned to the client. Throw a translated generic message, and log the detail.
-
com_ajax sets no-cache and no-index headers (
X-Robots-Tag: noindex, nofollow, caching disabled), so the endpoint is not stored by the page cache or indexed, and nothing about the response needs to be configured for that. -
Administrator-side Ajax calls from a module are rejected for unauthenticated users unless the helper method carries the
AllowUnauthorizedAdministratorAccessattribute, a safeguard for administrator endpoints.
If the endpoint grows beyond a handful of read-only calls, create a component with a proper controller and the Web
Services API of Web Services API instead; com_ajax is for small helpers.
The older Joomla.request code in books targets the same endpoint, but fetch() with async/await works equally
well; the core’s own Joomla.request remains the simplest way to get the default headers and error messages.
Security checklist for a module
-
Escape all output in the layout, with
htmlspecialchars(..., ENT_QUOTES, 'UTF-8'), including titles, aliases and parameter values.moduleclass_sfxis validated withCssIdentifier, and the standard core field should always be declared with that validation. -
Bind database parameters; use
whereIn(); never concatenate input into SQL. -
Filter on access level and publication state in the query, and honour the language filter.
-
Start every PHP file with
\defined('_JEXEC') or die;. -
Keep
com_ajaxhelper methods read-only, authorise inside them and return generic errors. -
Do not store secrets in module parameters: they are visible to anyone who can edit modules and may be exported or logged. An API key for a remote service (such as the park-authority API used by
plg_task_tallpine_trailsync) belongs in a plugin’s parameters with restricted access, or in the environment. -
Do not write to the file system from a layout. Use the Media Manager or
Joomla\Filesystemclasses (the old CMS filesystem classes moved to the Backward Compatibility 6 plugin in 6.0, for removal in 7.0). -
Use
Text::_()for strings and never put raw HTML in a language file value without a reason; a translator may edit it.
The wider treatment of validation, file uploads, CSRF and the extension checklist is in Security in Extensions.
Installing and trying the module
For a quick loop, run Joomla 6.2 in Docker and install the module as a zip. A Compose file for the Tallpine site is developed in Local Development with Docker; the minimum for this page is:
services:
joomla:
image: joomla:6.2-php8.3-apache
ports:
- "8080:80"
environment:
JOOMLA_DB_HOST: db
JOOMLA_DB_USER: tallpine
JOOMLA_DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
JOOMLA_DB_NAME: tallpine
volumes:
- ./mod_tallpine_nexthikes:/var/www/html/modules/mod_tallpine_nexthikes
depends_on:
- db
db:
image: mariadb:11.8
environment:
MARIADB_DATABASE: tallpine
MARIADB_USER: tallpine
MARIADB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- dbdata:/var/lib/mysql
volumes:
dbdata:
Source: Docker Official Image: joomla
Mounting the source folder over modules/mod_tallpine_nexthikes lets code edits show immediately, but the extension still
has to be known to Joomla. The simplest way is to zip the folder (with the manifest at the archive root) and install it:
cd mod_tallpine_nexthikes && zip -r ../mod_tallpine_nexthikes.zip . -x '*.DS_Store' && cd ..
Then:
-
open System > Install > Extensions, choose Upload Package File and upload the zip;
-
open Content > Site Modules > New, choose Tallpine: Next hikes;
-
set a title, the position
sidebar-right, the status Published and the menu assignment On all pages, and save.
The same flow is the first step of the Manual’s tutorial. With the volume mounted as above, run the install through
Install > Discover instead of the upload so Joomla registers the files that are already in modules/. Enable
System > Global Configuration > Debug System while developing, so a missing services folder or a wrong class name
shows the real error instead of a blank module.
Packaging the module with com_tallpine, the plugins and an installer script into pkg_tallpine, and publishing an
update server file, are covered in Extension Types,
Manifests and Packaging and Update Servers and the JED. To output the
module in a content area without a template position, the Load Module content plugin tags {loadmodule mod_tallpine_nexthikes}
and {loadmoduleid 12} work in articles, and <jdoc:include type="modules" name="..." /> is the template-side form.
Testing a module
The factory design exists to make modules testable.
-
Unit-test the Helper with a mock
DatabaseInterfaceset throughsetDatabase()and a mockedSiteApplication. No container is needed because the helper declares its inputs. -
Unit-test the Dispatcher by calling
setHelperFactory()with a fake factory that returns a stub helper, then reading the data fromgetLayoutData()through a small subclass that exposes it. The Manual points out that registering the helper factory with a mock namespace inservices/provider.phpreplaces a helper without touching the Dispatcher. -
End-to-end, publish an instance and request the page. Joomla’s own system tests run on Cypress, not on the Codeception setup that older books describe.
See Testing Extensions and, for the code-style and static-analysis tools that check the module (PSR-12 since 4.2), Coding Standards and Static Analysis.
Updating the module
Shipping version 1.1 means increasing <version> in the manifest, adding an <updateservers> entry (or a package
update) and, if a database change is needed, an <install> or <update> SQL section in the component that owns the
table. method="upgrade" lets the installer overwrite files. After installing the update, clear the cache: new
parameters appear in existing instances with their saved JSON, so the code must tolerate missing values, as said in
the parameters section. The Manual’s last tutorial step shows how to add an update server to a module, and
Installer Scripts and SQL Updates covers installer scripts for
data migrations.
Common errors and pitfalls
| Symptom | Usual cause |
|---|---|
The module installs but shows nothing |
|
|
The Dispatcher is missing |
|
The class is not in the helper namespace given to |
com_ajax answers "method does not exist" or "module not accessible" |
The helper is not named |
A static helper call fails: "non-static method should not be called statically" or the reverse |
The factory instantiates helpers; methods must be instance methods |
The layout shows stale data for guests |
Per-module caching is on; lower |
Edits to the layout seem ignored |
A template override exists at |
Blank page after upgrading to Joomla 6 with an old module |
A leftover |
Fields added in a new version are missing in old instances |
Saved parameters are not rewritten on update; read with a default |
Where books and older tutorials differ
-
Developing Extensions for Joomla! 5 (2023) teaches this design, including the
HelperFactory. Re-check its code against 6.x: it usesFactory::getLanguage()andget('DatabaseDriver')in places where the Dispatcher’s$this->appand the Aware traits are now used, and the genericEvent::getArgument()style in plugins where 5.0+ uses concrete event classes (see Plugin Development and Events). -
Joomla! 4 Masterclass (2023) is a site-builder’s book. It shows module creation and positions on Joomla 4.x, which reached end of life in October 2025; the screens it describes still exist, but the advice about
docs.joomla.orglinks is obsolete: use manual.joomla.org for development and guide.joomla.org for end users. -
Material from the 3.x era uses
mod_name.php, a staticModNameHelperloaded withJLoader::register()andJFactory::getDbo(). It still runs on a legacy path in some cases, but the legacyJ*class aliases are an opt-in of the Backward Compatibility 6 plugin on new 6.x sites, and the old design cannot use the container.