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:

flowchart TD T[Template outputs the jdoc include for modules] --> R[ModuleRenderer] R --> C{cache enabled?<br/>params cache=1 and global caching on} C -- yes --> MC[ModuleHelper::moduleCache] C -- no --> RM[ModuleHelper::renderModule] MC --> RM RM --> RR[renderRawModule] RR --> B[bootModule: load services/provider.php] B --> D[Module::getDispatcher] D --> DF[ModuleDispatcherFactory creates Site Dispatcher] DF --> HF[HelperFactory injected into the Dispatcher] HF --> DI[Dispatcher::dispatch] DI --> L[loadLanguage] L --> G[getLayoutData asks the Helper for data] G --> LP[ModuleHelper::getLayoutPath picks the layout file] LP --> O[Layout prints HTML] O --> CH[Chrome style card, none, ... wraps the HTML]

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 no mod_name.php entry 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 style of the position, such as card or none) wraps the layout’s output after the module has rendered. An instance whose layout prints nothing produces an empty content, and the card chrome 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\NextHikes to the src folder. 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 in services/provider.php.

<folder module="mod_tallpine_nexthikes">services</folder>

The module attribute names the module and is what the installer reads to register the extension element. It sits on the services folder in current modules; the older convention put it on <filename module="mod_x">mod_x.php</filename>. Forgetting to list services is 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 .ini for the front end and one .sys.ini for the installer, the Module Manager and the manifest description. See Language files.

<media>

Copies assets to media/mod_tallpine_nexthikes/. The destination is relative to media/, and folder is 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 params column. The fieldset named basic becomes the first tab, advanced the second. Fields of name="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 in Joomla\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\Dispatcher for the front end or <root namespace>\Administrator\Dispatcher\Dispatcher for the administrator, and falls back to the generic ModuleDispatcher of the core when that class does not exist. The Site segment belongs to the PHP namespace only. The files stay directly under src/Dispatcher/ and src/Helper/, as in the tree above: the core modules ship exactly this arrangement (for example modules/mod_articles_latest/src/Dispatcher/Dispatcher.php declares the namespace Joomla\Module\ArticlesLatest\Site\Dispatcher, with the manifest root Joomla\Module\ArticlesLatest). When in doubt, open a core module of the installed Joomla and copy its layout.

  • HelperFactory receives the helper namespace and instantiates helpers by short class name.

  • Module registers the extension object, the thing returned by Factory::getApplication()->bootModule().

Two pitfalls are specific to this file.

  • Two classes named ModuleDispatcherFactory. Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory is the service provider used above. Joomla\CMS\Dispatcher\ModuleDispatcherFactory is the factory it registers. A wrong use statement produces a confusing error about register() or createDispatcher() 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:

  1. calls loadLanguage(), which loads the module’s language file from the application base or from modules/<module name>/ unless it is already loaded;

  2. calls getLayoutData();

  3. stops silently if that returned false;

  4. 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() and Factory::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, and Factory::getDbo() is deprecated in favour of the container.

  • $db->getQuery(true), deprecated in favour of $db->createQuery().

  • A $db property assigned by magic: plugins used to get $app and $db set 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 or quote() of unvalidated input is the classic SQL injection. Bind targets must be variables, not literals, because bind() takes the value by reference.

  • Use whereIn() for lists with the third argument saying the type (ParameterType::STRING for 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 Factory calls for data, no business decisions. The Dispatcher and Helper already prepared everything; the layout may read $app for 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.json by itself. The layout calls addExtensionRegistryFile('mod_tallpine_nexthikes') and then useScript(). 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_PLATFORM is no longer defined (it is kept only through the Backward Compatibility 6 plugin), so older guards such as defined('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 the c format 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:

  1. templates/<active template>/html/mod_tallpine_nexthikes/default.php;

  2. when the active template is a child template, templates/<parent template>/html/mod_tallpine_nexthikes/default.php;

  3. modules/mod_tallpine_nexthikes/tmpl/default.php;

  4. modules/mod_tallpine_nexthikes/tmpl/default.php again 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, and validate="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 count to 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 showon to 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:

  1. the instance parameter cache is 1 ("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);

  2. the cachemode is neither id nor safeuri (those two are for modules that manage their own cache by calling ModuleHelper::moduleCache() from within the module);

  3. then the renderer wraps the whole ModuleHelper::renderModule() call in the cache controller of group mod_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 static is correct. The module’s cache_time of 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:

  1. format is mandatory. Without it com_ajax answers with an error.

  2. 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.

  3. The module is booted with $app->bootModule(). If the extension object is a HelperFactoryInterface, com_ajax asks it for the helper named ucfirst of every part of the module name, joined, plus Helper: for tallpine_nexthikes that is TallpineNexthikesHelper. This is the reason for the helper’s class name above. A helper called NextHikesHelper works for rendering (the Dispatcher asks for it by name) but is invisible to com_ajax.

  4. com_ajax calls the method <method>Ajax() on the helper (method=spots calls spotsAjax()), with no arguments. The default method name is get, so getAjax(). A method that does not end in Ajax can never be called this way.

  5. The return value goes through JsonResponse when format=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 in com_tallpine, not in an Ajax helper.

  • Validate input with the typed getters of the input (getInt, getCmd, getWord), as spotsAjax() 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 AllowUnauthorizedAdministratorAccess attribute, 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_sfx is validated with CssIdentifier, 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_ajax helper 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\Filesystem classes (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:

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:

  1. open System > Install > Extensions, choose Upload Package File and upload the zip;

  2. open Content > Site Modules > New, choose Tallpine: Next hikes;

  3. 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 DatabaseInterface set through setDatabase() and a mocked SiteApplication. 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 from getLayoutData() through a small subclass that exposes it. The Manual points out that registering the helper factory with a mock namespace in services/provider.php replaces 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

services not listed in <files>; the namespace in services/provider.php does not match the manifest <namespace>; a typo in a directory name that only fails on a case-sensitive file system

UnexpectedValueException: HelperFactory not set

The Dispatcher is missing implements HelperFactoryAwareInterface or use HelperFactoryAwareTrait

Call to undefined method ...::getHikes() or a null helper

The class is not in the helper namespace given to HelperFactory, or its name differs from the string in getHelper(); getHelper() returns null for an unknown class

com_ajax answers "method does not exist" or "module not accessible"

The helper is not named <Parts>Helper (here TallpineNexthikesHelper), the method does not end in Ajax, the module is disabled, or format is missing

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 cache_time, disable the cache, or load the volatile part with AJAX

Edits to the layout seem ignored

A template override exists at templates/<template>/html/mod_tallpine_nexthikes/ and wins over tmpl/

Blank page after upgrading to Joomla 6 with an old module

A leftover defined('JPATH_PLATFORM') or die; guard, a removed CMS class (\Joomla\CMS\Input, JFactory aliases) used without the Backward Compatibility 6 plugin, or a $this->db property read from AdminModel-style code

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 uses Factory::getLanguage() and get('DatabaseDriver') in places where the Dispatcher’s $this->app and the Aware traits are now used, and the generic Event::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.org links 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 static ModNameHelper loaded with JLoader::register() and JFactory::getDbo(). It still runs on a legacy path in some cases, but the legacy J* 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.