Component Site Views and Routing

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.

The site (front-end) half of a Joomla component has four jobs: turn a request into a view, load the data for that view through a model, render it with a layout file, and produce readable, stable URLs for every page in both directions. This page covers all four for the Tallpine Hiking Club component com_tallpine: the trail list and the trail detail pages, the XML files that make them selectable as menu item types, the pagination of the list, the assets they load, and the SEF router that maps /trails/12-lake-tarn-circuit.html to index.php?option=com_tallpine&view=trail&id=12.

It was written against Joomla 6.2 (the current release; 5.4 is in its security-only period and behaves the same for everything below unless stated) and PHP 8.3 or later, and checked on 2026-10-30. The component namespace is Tallpine\Component\Tallpine, the package is pkg_tallpine. The administrator side (trail entity, tables, edit form, toolbar) is in Component MVC: the Administrator Side; the container, services/provider.php and the Extension class are in Namespaces, Autoloading and Dependency Injection; the manifest and packaging are in Extension Types, Manifests and Packaging. The user-facing side of the same topics (how an editor creates menu items and how the SEF settings behave) is in Menus and Menu Items and SEO: SEF URLs, Redirects and Schema.org. PHP language features used below are taught in the PHP reference; namespaces and PSR-4 autoloading in particular are in Namespaces and Autoloading.

The site side of a component

A Joomla component has two independent code trees, one per application client: the administrator tree (administrator/components/com_tallpine) and the site tree (components/com_tallpine). They are installed from the <files folder="site"> and <administration> sections of the manifest, share the database tables and the component Extension class registered in the administrator tree, and have separate namespaces below the component namespace:

Namespace Directory

Tallpine\Component\Tallpine\Administrator\...

administrator/components/com_tallpine/src

Tallpine\Component\Tallpine\Site\...

components/com_tallpine/src

Tallpine\Component\Tallpine\Api\...

api/components/com_tallpine/src (see Extending the Web Services API)

The site tree of com_tallpine looks like this:

components/com_tallpine/
  src/
    Controller/DisplayController.php
    Dispatcher/Dispatcher.php          (optional, see below)
    Helper/RouteHelper.php
    Model/TrailsModel.php              (list model)
    Model/TrailModel.php               (item model)
    Service/Router.php                 (the SEF component router)
    View/Trails/HtmlView.php
    View/Trail/HtmlView.php
  tmpl/
    trails/default.php                 (layout "default": table of trails)
    trails/default.xml                 (menu item type "Trail List")
    trails/cards.php                   (alternative layout "cards")
    trails/cards.xml                   (menu item type "Trail Cards")
    trail/default.php
    trail/default.xml                  (menu item type "Single Trail")
media/com_tallpine/
  joomla.asset.json
  css/trails.css
  js/trail-filter.js

The folder names below src/View and tmpl are the view names (Trails and trails), and the file names inside tmpl are the layout names. That convention, not any registration, is what lets the request view=trails&layout=cards find tmpl/trails/cards.php. Two further facts shape everything on this page. First, a menu item does not point at code; it stores a link (index.php?option=com_tallpine&view=trails) plus parameters, and the router and dispatcher do the rest. Second, the component is booted through the dependency-injection container: services/provider.php registers an MVC factory, a dispatcher factory and a router factory for the namespace, and the Extension class implements RouterServiceInterface through RouterServiceTrait so that the site router can ask the component for its router.

From request to layout: the site dispatch chain

sequenceDiagram participant B as Browser participant S as SiteApplication and SiteRouter participant D as ComponentDispatcher participant C as DisplayController participant M as TrailsModel participant V as View Trails HtmlView participant L as tmpl trails default.php B->>S: GET /trails/12-lake-tarn-circuit.html S->>S: parse path: menu item "trails" then com_tallpine router S->>D: option=com_tallpine view=trail id=12 Itemid=105 D->>D: checkAccess and load the component language file D->>C: dispatch task display C->>V: getView and setModel V->>M: getItem M-->>V: trail object or 404 V->>L: loadTemplate default L-->>B: HTML inside the template index.php

The steps in order:

  1. The SiteRouter parses the path (see The SEF router for a component), sets option, view, id and Itemid, makes the matching menu item active and merges the menu item’s request variables.

  2. The SiteApplication boots the component (bootComponent('com_tallpine')) and asks it for a dispatcher. A component without its own Dispatcher class gets the generic ComponentDispatcher, which loads the component language file, runs checkAccess() and picks the controller from the task (default display) or the controller request value.

  3. The DisplayController instantiates the view named by the view request variable and the model of the same name, assigns the model to the view and calls display().

  4. The view reads data from the model, prepares the document (title, metadata, assets) and calls loadTemplate() for the layout. Layout lookup order is: the active template’s html/com_tallpine/trails/default.php override first, then components/com_tallpine/tmpl/trails/default.php. The override mechanism is in Template Overrides and Alternative Layouts.

  5. The rendered output replaces <jdoc:include type="component" /> in the template index.php.

A custom Dispatcher is only needed to add component-wide checks. Core com_content overrides dispatch() to authorise modal editor layouts before calling the parent. For com_tallpine the generic dispatcher is enough, so the component ships none. Older books such as Developing Extensions for Joomla! 5 (2023) describe this chain accurately; the corrections that matter on 6.x are the ones listed in Version notes and book corrections.

The display controller

The site controller is usually tiny. DisplayController extends BaseController and sets the default view; the useful work is passing a safe list of URL parameters to the page cache so that cached pages are keyed correctly:

<?php

namespace Tallpine\Component\Tallpine\Site\Controller;

use Joomla\CMS\MVC\Controller\BaseController;

\defined('_JEXEC') or die;

class DisplayController extends BaseController
{
    protected $default_view = 'trails';

    public function display($cachable = false, $urlparams = [])
    {
        // Only anonymous GET requests are safe to cache for the whole page.
        $cachable = $this->app->getIdentity()->guest && $this->input->getMethod() === 'GET';

        $safeurlparams = [
            'catid'      => 'INT',
            'id'         => 'INT',
            'limit'      => 'UINT',
            'limitstart' => 'UINT',
            'filter'     => 'STRING',
            'lang'       => 'CMD',
            'Itemid'     => 'INT',
        ];

        return parent::display($cachable, $safeurlparams);
    }
}

The $urlparams array is the whitelist of request variables that become part of the cache key when the page cache is on. A variable missing from it is ignored for caching, so two different pages can be served from one cache entry: always list every variable that changes what the page shows (id, limitstart, Itemid, lang, filters). com_content forces $cachable = false for logged-in users, and so does the example, because trail conditions for the Trail Warden group differ per user. Caching layers are covered in Caching and Performance.

Models for the site views

Site models use the same base classes as the administrator: ListModel for collections and ItemModel (or AdminModel when editing) for a single record. What changes on the site is the filtering: only published records, only the access levels of the current user, only the current language, and only what the menu item parameters ask for.

<?php

namespace Tallpine\Component\Tallpine\Site\Model;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Multilanguage;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;

\defined('_JEXEC') or die;

class TrailsModel extends ListModel
{
    public function __construct($config = [], $factory = null)
    {
        $config['filter_fields'] = ['title', 'a.title', 'distance_km', 'a.distance_km', 'difficulty', 'a.difficulty'];

        parent::__construct($config, $factory);
    }

    protected function populateState($ordering = 'a.title', $direction = 'ASC')
    {
        // The core site models read the application the same way; the base model
        // classes do not provide an application getter.
        $app    = Factory::getApplication();
        $params = $app->getParams();

        $this->setState('params', $params);
        $this->setState('filter.language', Multilanguage::isEnabled());
        $this->setState('filter.language_tag', $app->getLanguage()->getTag());
        $this->setState('filter.catid', $app->getInput()->getInt('catid', (int) $params->get('catid', 0)));

        // ListModel reads list.limit and list.start (from limit and limitstart) here.
        parent::populateState($ordering, $direction);

        // Menu item parameter "Items per page" wins over the global default.
        if ((int) $params->get('trails_per_page', 0) > 0 && !$app->getInput()->getUint('limit')) {
            $this->setState('list.limit', (int) $params->get('trails_per_page'));
        }
    }

    protected function getListQuery(): QueryInterface
    {
        $db    = $this->getDatabase();
        $query = $db->createQuery();

        $query->select($db->quoteName(['a.id', 'a.title', 'a.alias', 'a.catid', 'a.summary', 'a.distance_km',
            'a.elevation_gain', 'a.difficulty', 'a.access', 'a.language']))
            ->from($db->quoteName('#__tallpine_trails', 'a'))
            ->where($db->quoteName('a.state') . ' = 1')
            ->whereIn($db->quoteName('a.access'), $this->getCurrentUser()->getAuthorisedViewLevels());

        if ($this->getState('filter.language')) {
            $query->whereIn(
                $db->quoteName('a.language'),
                [$this->getState('filter.language_tag'), '*'],
                ParameterType::STRING
            );
        }

        if ($catid = (int) $this->getState('filter.catid')) {
            $query->where($db->quoteName('a.catid') . ' = :catid')
                ->bind(':catid', $catid, ParameterType::INTEGER);
        }

        $ordering  = $db->escape($this->getState('list.ordering', 'a.title'));
        $direction = strtoupper($this->getState('list.direction', 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
        $query->order($db->quoteName($ordering) . ' ' . $direction);

        return $query;
    }
}

The points that matter:

  • filter_fields is the whitelist of columns the list ordering may use. ListModel ignores an ordering that is not in it, which is what stops a visitor from putting arbitrary SQL into filter_order. Never build ORDER BY from a request value without such a whitelist.

  • whereIn() with the user’s authorised view levels is the site access check. getCurrentUser() comes from the CurrentUserTrait that the base models use; Factory::getUser() is deprecated. The Trail Warden member-only conditions are an access level, so a guest simply never sees those rows in the list.

  • The database comes from getDatabase() (the DatabaseAwareTrait); Factory::getDbo() is not used. Values are never concatenated: bind() with a ParameterType and whereIn() produce prepared statements. See Database API.

  • createQuery() is the Joomla 5/6 spelling of the older getQuery(true).

  • The language filter is applied only when the multilingual feature is on; see Multilingual Sites.

The item model is a thin ItemModel that loads one row, applies the same published/access/language conditions and caches the result per primary key. Its getItem() must return false or null for a missing, unpublished or inaccessible record so that the view can raise a 404:

<?php

namespace Tallpine\Component\Tallpine\Site\Model;

use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\Database\ParameterType;

\defined('_JEXEC') or die;

class TrailModel extends ItemModel
{
    public function getItem($pk = null)
    {
        $pk = (int) ($pk ?: $this->getState('trail.id'));

        if (!isset($this->_item[$pk])) {
            $db    = $this->getDatabase();
            $query = $db->createQuery()
                ->select($db->quoteName(['a.id', 'a.title', 'a.alias', 'a.catid', 'a.summary', 'a.description',
                    'a.distance_km', 'a.elevation_gain', 'a.difficulty', 'a.access', 'a.language', 'a.metadesc']))
                ->from($db->quoteName('#__tallpine_trails', 'a'))
                ->where($db->quoteName('a.id') . ' = :id')
                ->where($db->quoteName('a.state') . ' = 1')
                ->whereIn($db->quoteName('a.access'), $this->getCurrentUser()->getAuthorisedViewLevels())
                ->bind(':id', $pk, ParameterType::INTEGER);

            $this->_item[$pk] = $db->setQuery($query)->loadObject() ?: false;
        }

        return $this->_item[$pk];
    }

    protected function populateState()
    {
        // The id comes from the request; the display controller can also set it.
        $this->setState('trail.id', \Joomla\CMS\Factory::getApplication()->getInput()->getInt('id'));
    }
}

A quiet trap: ItemModel::getItem() must not throw for a missing record. A model that throws a plain \Exception produces a 500 page, while the view decides between 404 (not found) and 403 (found but not allowed).

Site views

A view class prepares everything the layout needs and keeps the layout free of logic. In Joomla 5.3 and later the model is fetched with getModel() and called directly; the old $this->get('Items') magic that forwards to getItems() on the model is deprecated since 5.3 and removed in 7.0, although many extensions and the books still use it (core com_content still contains it in places, but new code should not).

<?php

namespace Tallpine\Component\Tallpine\Site\View\Trails;

use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;

\defined('_JEXEC') or die;

class HtmlView extends BaseHtmlView
{
    protected $items = [];
    protected $pagination;
    protected $state;
    protected $params;

    public function display($tpl = null)
    {
        $model = $this->getModel();

        $this->items      = $model->getItems();
        $this->pagination = $model->getPagination();
        $this->state      = $model->getState();
        $this->params     = $this->state->get('params');

        if (\count($errors = $model->getErrors())) {
            throw new GenericDataException(implode("\n", $errors), 500);
        }

        // Page one keeps its canonical URL: do not add limitstart=0 to the links.
        $this->pagination->hideEmptyLimitstart = true;

        $this->prepareDocument();

        parent::display($tpl);
    }

    protected function prepareDocument(): void
    {
        $menu  = Factory::getApplication()->getMenu()->getActive();
        $title = $menu && $menu->query['view'] === 'trails'
            ? $menu->title
            : Text::_('COM_TALLPINE_TRAILS_TITLE');

        $this->setDocumentTitle($this->params->get('page_title', $title));

        if ($desc = $this->params->get('menu-meta_description')) {
            $this->getDocument()->setDescription($desc);
        }

        if ($robots = $this->params->get('robots')) {
            $this->getDocument()->setMetaData('robots', $robots);
        }

        // Load the assets declared in media/com_tallpine/joomla.asset.json.
        $wa = $this->getDocument()->getWebAssetManager();
        $wa->useStyle('com_tallpine.trails')
            ->useScript('com_tallpine.trail-filter');
    }
}

Notes on the view:

  • setDocumentTitle() applies the global Site Name in Page Titles option (before, after or off); do not call $document->setTitle() directly with a hand-built string. getDocument() replaced the older Factory::getDocument() calls in views.

  • The GenericDataException branch is the standard way a view surfaces model errors from the old setError() mechanism. New models should throw exceptions; core drops the setError() support in 7.0.

  • The base HtmlView offers getDocument() and getCurrentUser() but no application getter, so core site views call Factory::getApplication() to reach the application. A site view should read the active menu item from it and let the menu item’s parameters (page_title, menu-meta_description, robots, pageclass_sfx) override the component defaults, exactly as the user-facing menu item form promises (see Menus and Menu Items, "Page Display" and "Metadata").

  • The pagination object is created by ListModel::getPagination() from the model state (list.start, list.limit) and the total row count.

The detail view differs in three ways: it loads one item, it turns "not found" and "not allowed" into exceptions with the right HTTP status, and it sets the page title and meta description from the record.

<?php

namespace Tallpine\Component\Tallpine\Site\View\Trail;

use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;

\defined('_JEXEC') or die;

class HtmlView extends BaseHtmlView
{
    protected $item;
    protected $params;

    public function display($tpl = null)
    {
        $model = $this->getModel();
        $item  = $model->getItem();

        if (!$item) {
            // Unpublished, missing and not-viewable rows look the same to a visitor.
            throw new \Exception(Text::_('COM_TALLPINE_ERROR_TRAIL_NOT_FOUND'), 404);
        }

        $this->item   = $item;
        $this->params = $model->getState()->get('params') ?? new \Joomla\Registry\Registry();

        $this->setDocumentTitle($item->title);

        if ($item->metadesc) {
            $this->getDocument()->setDescription($item->metadesc);
        }

        $this->getDocument()->getWebAssetManager()->useStyle('com_tallpine.trails');

        parent::display($tpl);
    }
}

Raising a 404 for a record the user may not see (instead of 403) avoids confirming that a members-only trail exists. Core com_content returns 403 for a known article the user cannot view, which is correct for content that is public by title but restricted by body. Pick one policy per entity and apply it in the model’s query and in the view. A model that filters by access level never reaches the 403 branch; a model that does not must check authorise() in the view. The generic error pages are customised in Error, Offline and Component Pages, and exception handling in Error Handling and Legacy APIs.

Layouts

Layouts are plain PHP files that echo HTML. They run inside the view object, so $this is the view and every protected property of the view (items, pagination, params) is readable. Two rules keep them safe and overridable: escape every dynamic value, and never run queries.

<?php

use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Tallpine\Component\Tallpine\Site\Helper\RouteHelper;

\defined('_JEXEC') or die;
?>
<div class="com-tallpine-trails">
    <?php if ($this->params->get('show_page_heading')) : ?>
        <h1><?php echo $this->escape($this->params->get('page_heading') ?: $this->params->get('page_title')); ?></h1>
    <?php endif; ?>

    <form action="<?php echo htmlspecialchars(Route::_('index.php?option=com_tallpine&view=trails'), ENT_QUOTES, 'UTF-8'); ?>"
          method="post" name="adminForm" id="adminForm">
        <table class="table com-tallpine-trails__table">
            <caption class="visually-hidden"><?php echo Text::_('COM_TALLPINE_TRAILS_TABLE_CAPTION'); ?></caption>
            <thead>
                <tr>
                    <th scope="col"><?php echo Text::_('COM_TALLPINE_FIELD_TITLE'); ?></th>
                    <th scope="col"><?php echo Text::_('COM_TALLPINE_FIELD_DISTANCE_KM'); ?></th>
                    <th scope="col"><?php echo Text::_('COM_TALLPINE_FIELD_DIFFICULTY'); ?></th>
                </tr>
            </thead>
            <tbody>
            <?php foreach ($this->items as $item) : ?>
                <tr>
                    <th scope="row">
                        <a href="<?php echo Route::_(RouteHelper::getTrailRoute($item->id . ':' . $item->alias, $item->catid, $item->language)); ?>">
                            <?php echo $this->escape($item->title); ?>
                        </a>
                    </th>
                    <td><?php echo (float) $item->distance_km; ?> km</td>
                    <td><?php echo $this->escape($item->difficulty); ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>

        <?php if ($this->pagination->pagesTotal > 1) : ?>
            <div class="com-tallpine-trails__pagination">
                <p class="counter"><?php echo $this->pagination->getPagesCounter(); ?></p>
                <?php echo $this->pagination->getPagesLinks(); ?>
            </div>
        <?php endif; ?>
    </form>
</div>

$this->escape() is the view’s output-escaping helper (htmlspecialchars with the right flags). Escaping in the layout rather than in the model is what makes the data reusable for the API and the CLI. Every user-supplied or database string that reaches HTML needs it: a trail title is editor-entered content, and an unescaped title is a stored cross-site scripting hole. The one value cast with (float) is a number. HTML that editors write (the trail description) is filtered on save according to their text filter group and is echoed with the content plugin pipeline instead of escape(); see Users, Groups, Access Levels and ACL for the text filters.

Alternative layouts are extra files in the same view folder. tmpl/trails/cards.php is an alternative layout of the trails view with the same view class and model. Selected in a menu item, it adds layout=cards to the link. Because each layout has an XML file with the same base name, it appears as its own entry in the Select a Menu Item Type dialog. The layout may call sub-templates with $this->loadTemplate('card'), which includes cards_card.php (the view name is not part of the sub-template file name; the layout name, an underscore and the argument are).

Layouts are also what the template overrides feature replaces. A child template cassiopeia_tallpine that contains html/com_tallpine/trails/default.php takes precedence over the component file without touching the component, and the Template Manager reports the override as out of date when a component update changes the original; both are documented in Template Overrides and Alternative Layouts and Child Templates.

Menu item types: tmpl XML files

The XML file next to a layout is what makes the layout a menu item type. When an editor chooses Menus > Tallpine Main Menu > New > Select, Joomla scans the tmpl/*/*.xml files of every installed component, groups them by component and shows each <layout title="..."> as a selectable type. The XML also defines the fields of the menu item form. The type default.xml for the trail list:

<?xml version="1.0" encoding="UTF-8"?>
<metadata>
    <layout title="COM_TALLPINE_TRAILS_VIEW_DEFAULT_TITLE" option="COM_TALLPINE_TRAILS_VIEW_DEFAULT_OPTION">
        <message>
            <![CDATA[COM_TALLPINE_TRAILS_VIEW_DEFAULT_DESC]]>
        </message>
    </layout>

    <!-- Request variables: become part of the menu item link and of the request. -->
    <fields name="request">
        <fieldset name="request" addfieldprefix="Joomla\Component\Categories\Administrator\Field">
            <field
                name="catid"
                type="modal_category"
                label="JGLOBAL_CHOOSE_CATEGORY_LABEL"
                extension="com_tallpine"
                select="true"
                new="true"
                edit="true"
                clear="true"
            />
        </fieldset>
    </fields>

    <!-- Parameters: stored in the menu item params and merged into the component params. -->
    <fields name="params">
        <fieldset name="basic" label="COM_TALLPINE_TRAILS_OPTIONS">
            <field
                name="trails_per_page"
                type="number"
                label="COM_TALLPINE_FIELD_TRAILS_PER_PAGE_LABEL"
                default="20"
                min="1"
                max="100"
                filter="integer"
            />
            <field
                name="show_difficulty"
                type="radio"
                label="COM_TALLPINE_FIELD_SHOW_DIFFICULTY_LABEL"
                layout="joomla.form.field.radio.switcher"
                default="1"
                filter="integer"
                >
                <option value="0">JNO</option>
                <option value="1">JYES</option>
            </field>
        </fieldset>
    </fields>
</metadata>

How the pieces map to stored data:

  • <layout title option>: title is a language constant for the type’s name in the selection dialog; option is the label of the type’s entry in the menu item’s Link field. Both constants belong in the administrator-side com_tallpine.sys.ini file, because the dialog is rendered in the administrator application; see Language Files and Internationalisation.

  • <fields name="request">: values here are written into the link column of the menu item (index.php?option=com_tallpine&view=trails&catid=8). They are request variables, so the router puts them in $query and MenuRules compare them when it searches the menu item that best matches a URL being built. A menu item with a different catid than the one in the URL is not a match.

  • <fields name="params">: values are stored as JSON in the menu item’s params column and merged into the component parameters, so $app->getParams() (which the models above read through state params) returns the menu item value when one is set and the component default otherwise. Any field can offer useglobal="true" to show Use Global as its first choice, which makes the menu item inherit the value from the component Options screen.

  • A <fieldset name="basic"> creates a tab or group on the right of the form; request fields appear in the Details tab.

  • Menu item types for non-public views (an edit form, a modal layout) are hidden from the dialog by adding <layout hidden="true">.

The Single Trail type (tmpl/trail/default.xml) is the same shape with <field name="id" type="modal_trail" required="true" /> as its request field, which turns the menu item into "always show trail 12". The administrator side provides the modal field class; its design is in Forms and Form Fields.

A menu item’s link must always name a view. A menu item of the type Trail List whose link is stored without view=trails (for example after a manual edit of the link column) falls back to the controller’s $default_view, but the router’s lookup table ignores it because it matches by view.

The SEF router for a component

Joomla has two routing levels. The application router (SiteRouter) handles the language prefix, the menu item path and the SEF suffix. The component router handles the segments that remain after the menu item path: article aliases and category paths in com_content, trail identifiers in com_tallpine. The Manual introduction to routing explains the split; this section shows how to write the component half with RouterView.

How the router is found

// services/provider.php (administrator/components/com_tallpine/services/provider.php)
$container->registerServiceProvider(new MVCFactory('\\Tallpine\\Component\\Tallpine'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Tallpine\\Component\\Tallpine'));
$container->registerServiceProvider(new RouterFactory('\\Tallpine\\Component\\Tallpine'));

$container->set(
    ComponentInterface::class,
    function (Container $container) {
        $component = new TallpineComponent($container->get(ComponentDispatcherFactoryInterface::class));
        $component->setMVCFactory($container->get(MVCFactoryInterface::class));
        $component->setRouterFactory($container->get(RouterFactoryInterface::class));

        return $component;
    }
);

The Extension class declares implements RouterServiceInterface and use RouterServiceTrait;. The registered RouterFactory creates the router by class name from the application name: for the site it instantiates Tallpine\Component\Tallpine\Site\Service\Router, and for the API application it would look for ...\Api\Service\Router. If the class does not exist a RuntimeException ("No router available for this application.") is raised. The factory passes the constructor ($app, $menu, $categoryFactory, $db): the category factory is null when the component did not register a CategoryFactory provider. Router classes may declare only the first two parameters, but a router that queries aliases (see below) should accept the database and not call Factory::getContainer() itself.

Declaring the views

The RouterView base class turns a declarative description of the component’s views into build and parse behaviour. Each view is a RouterViewConfiguration; the methods are fluent:

Method Meaning

new RouterViewConfiguration('trail')

the view name, equal to the folder in tmpl and the view request value

setKey('id')

the request variable that identifies one record of this view. A view with a key represents a single item; a view without a key (a list, featured) represents a page.

setParent($parentConfig, 'catid')

places the view below another view in the URL hierarchy; the optional second argument is the request variable in which a child remembers the parent’s identity (an article’s catid for the category view)

setNestable()

the view’s items form a tree (categories): a URL contains one segment for each level

addLayout('cards')

registers an additional layout that menu items of this view may use; the default layout is default

removeLayout('default')

the reverse of addLayout()

RouterView::registerView() adds the configuration to the router; getViews() returns the registered ones and getPath() computes, for a query, the chain of views from the root to the requested view with the IDs and segments of each level.

The Tallpine router

<?php

namespace Tallpine\Component\Tallpine\Site\Service;

use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Component\Router\RouterViewConfiguration;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Component\Router\Rules\NomenuRules;
use Joomla\CMS\Component\Router\Rules\PreprocessRules;
use Joomla\CMS\Component\Router\Rules\StandardRules;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;

\defined('_JEXEC') or die;

class Router extends RouterView
{
    /** When true, URLs contain the alias only; when false, "id-alias". */
    protected bool $noIDs;

    private ?DatabaseInterface $db;

    public function __construct(
        SiteApplication $app,
        AbstractMenu $menu,
        ?CategoryFactoryInterface $categoryFactory = null,
        ?DatabaseInterface $db = null
    ) {
        $this->db    = $db;
        $this->noIDs = (bool) ComponentHelper::getParams('com_tallpine')->get('sef_ids', 0);

        // List page: no key, one menu item of the type "Trail List" or "Trail Cards".
        $trails = new RouterViewConfiguration('trails');
        $trails->addLayout('cards');
        $this->registerView($trails);

        // Detail page: identified by "id", a child of the list.
        $trail = new RouterViewConfiguration('trail');
        $trail->setKey('id')->setParent($trails);
        $this->registerView($trail);

        parent::__construct($app, $menu);

        // Complete "12" to "12:lake-tarn-circuit" before the other rules look at the query.
        $preprocess = new PreprocessRules($trail, '#__tallpine_trails', 'id');
        $preprocess->setDatabase($this->db);

        $this->attachRule($preprocess);
        $this->attachRule(new MenuRules($this));
        $this->attachRule(new StandardRules($this));
        $this->attachRule(new NomenuRules($this));
    }

    /**
     * Build: ID (or "ID:alias") to the segment(s) of the URL.
     * The returned array maps an ID to the segment text, because rules need both.
     */
    public function getTrailSegment($id, $query)
    {
        if ($this->noIDs && str_contains((string) $id, ':')) {
            [$numeric, $alias] = explode(':', $id, 2);

            return [(int) $numeric => $alias];
        }

        return [(int) $id => (string) $id];
    }

    /**
     * Parse: one segment of the URL to the ID, or false when it matches nothing.
     */
    public function getTrailId($segment, $query)
    {
        if ($this->noIDs) {
            $dbq = $this->db->createQuery()
                ->select($this->db->quoteName('id'))
                ->from($this->db->quoteName('#__tallpine_trails'))
                ->where($this->db->quoteName('alias') . ' = :alias')
                ->bind(':alias', $segment);

            $id = (int) $this->db->setQuery($dbq)->loadResult();

            if ($id) {
                return $id;
            }

            // Not an alias: maybe an old "12-lake-tarn-circuit" URL from before IDs were removed.
            $this->app->getRouter()->setTainted();
        }

        $id = (int) $segment;

        if ($id) {
            $dbq = $this->db->createQuery()
                ->select($this->db->quoteName('alias'))
                ->from($this->db->quoteName('#__tallpine_trails'))
                ->where($this->db->quoteName('id') . ' = :id')
                ->bind(':id', $id, ParameterType::INTEGER);

            $alias = $this->db->setQuery($dbq)->loadResult();

            // "12" or "12-wrong-alias": the trail exists, but the URL is not canonical.
            if ($alias && $id . '-' . $alias !== $segment) {
                $this->app->getRouter()->setTainted();
            }
        }

        return $id;
    }
}

This is the same structure as com_content's router, reduced to two views. Details worth knowing:

  • The class must be named Router in the Site\Service namespace to be found by RouterFactory, and it must extend RouterView (or implement RouterInterface with preprocess(), build() and parse() by hand, which is only worth doing for routing that does not fit a view hierarchy).

  • The component name used internally (getName()) is derived from the class namespace, which is how the rules know that Tallpine\Component\Tallpine\Site\Service\Router belongs to com_tallpine. Keep the namespace and the folder com_tallpine aligned.

  • The callbacks are found by name: for a view trail the router calls getTrailSegment and getTrailId. The pattern is get<Viewname>Segment and get<Viewname>Id with the view name capitalised by ucfirst(). Both receive $query, the current state of the request being built or parsed, so a segment can depend on other variables such as the category. A view with a key but no callbacks falls back to using the raw value as the segment.

  • The build callback returns an array so that a nested view can return several id => segment pairs; StandardRules later picks the segments it needs and replaces : with -, which is why 12:lake-tarn-circuit appears as 12-lake-tarn-circuit.

  • The parse callback returns the ID, or a falsy value if the segment matches nothing in the current context, which the rules read as "this segment is not mine" and leave unconsumed. Unconsumed segments produce a 404.

  • The IDs in the example are cast to int, and the parse query uses bind(). The segment is attacker-controlled input taken straight from the URL: never concatenate it into SQL.

The rules

Rules are small classes implementing RulesInterface (preprocess(), build(), parse()). RouterView runs them in the order they were attached for each phase, and the order matters.

Rule Phase(s) What it does

PreprocessRules

preprocess

Optional helper for a view with a database table. When the query has view=trail&id=12 without the alias, it reads the alias (and optionally the parent key) from the table and rewrites the value to 12:lake-tarn-circuit. This is why a plain Route::_('index.php?option=com_tallpine&view=trail&id=12') still produces the alias in the URL. Attach it first.

MenuRules

preprocess

Chooses the Itemid of the menu item that is the best base for the URL being built. It builds a lookup table of all menu items of the component by language, view, layout and key, walks the view path of the query from the most specific view up, and picks the first menu item that matches (a Single Trail item for trail 12, otherwise the Trail List item).

StandardRules

build and parse

Builds and parses the part of the URL after the menu item path when a suitable menu item exists: it removes from the query everything the menu item already says (view, key, layout) and appends the segments for the rest. On parse it consumes segments, calling getTrailId() for each, and sets view and id.

NomenuRules

build and parse

The fallback for URLs for which no menu item of the component exists: the URL contains the view name and the key segment. It is why a trail still has a working URL if the Trail List menu item is unpublished.

The same URL, built under three conditions (suffix on, IDs kept):

Situation Resulting URL

A Trail List menu item with alias trails is published

/trails/12-lake-tarn-circuit.html

A Single Trail menu item for trail 12 exists (alias lake-tarn)

/lake-tarn.html (the menu item already fixes view and id)

No menu item of com_tallpine exists

/component/tallpine/trail/12-lake-tarn-circuit.html (prefix added by the site router; the home menu item is used as base)

Rule order and custom rules: a rule can be replaced or complemented. A system plugin can attach an extra rule to the component’s router ($router->attachRule(new MyRule())) after boot; core uses the same extension point. Rules that need the database should implement DatabaseAwareInterface as PreprocessRules does. Do not call detachRule() on core rules unless you know the consequences: removing NomenuRules is the usual cause of "my component only works while a menu item exists".

The Itemid that MenuRules chooses can fall back to the active menu item of the same component, or the language-specific home item, when nothing in the lookup matches; this fallback is disabled when the Strict Routing option of the System - SEF plugin is on, in which case a URL built without a suitable menu item gets no Itemid and NomenuRules apply. The details of the System - SEF plugin are in SEO: SEF URLs, Redirects and Schema.org.

Building a URL, step by step

flowchart TD A["Route::_ with option=com_tallpine view=trail id=12"] --> B["SiteRouter preprocess: ask the component router"] B --> C["PreprocessRules: id 12 becomes 12:lake-tarn-circuit"] C --> D{"MenuRules: menu item for view trail and id 12?"} D -- yes --> E["Itemid of the Single Trail item"] D -- no --> F{"menu item for view trails?"} F -- yes --> G["Itemid of the Trail List item"] F -- no --> H["active item of the component, or home item"] E --> I["StandardRules build: drop view and id, no segments left"] G --> J["StandardRules build: segment 12-lake-tarn-circuit"] H --> K["NomenuRules build: segments trail and 12-lake-tarn-circuit"] I --> L["Prepend menu item path, add language prefix and suffix"] J --> L K --> L L --> M["/trails/12-lake-tarn-circuit.html"]

Passing an Itemid to Route::_() is a hint: the Manual points out that the router may base the URL on a different menu item, because the preprocess() step has the last word unless the passed Itemid is already the active item or an exact match. Hidden menu items count as candidates; unpublished ones do not. When no menu item of the component exists at all, the home page is the base, which the Manual calls a situation to avoid. The reliable way to get stable URLs is therefore to create menu items for the views you link to, as the Tallpine setup guide does for the Trails menu.

Parsing a URL, step by step

flowchart TD A["GET /trails/12-lake-tarn-circuit.html"] --> B["SiteRouter: strip suffix and language prefix"] B --> C["Match segments against menu item aliases: trails matches"] C --> D["Active menu item = Trail List, request gets option and view=trails"] D --> E["Component router parse with remaining segments: 12-lake-tarn-circuit"] E --> F["StandardRules parse: getTrailId segment"] F -- "id found" --> G["view=trail and id=12"] F -- "not found" --> H["segment left over"] G --> I{"Canonical segment?"} I -- yes --> J["Dispatch com_tallpine"] I -- "no, setTainted" --> K["Dispatch, then System - SEF issues one 301 to the canonical URL"] H --> L["404 Not Found"]

Because StandardRules::parse() starts from the active menu item’s query merged with the variables found so far, the same segment 12-lake-tarn-circuit is read as "trail 12" below the Trail List item but not below, say, a Board Contacts item of another component. Which component receives the leftover segments is decided by the menu item; the component router never sees segments belonging to another component.

Segments, IDs and aliases

Three URL designs are possible for a keyed view, and the router above supports the first two through one option:

Design Example Notes

ID and alias (default)

/trails/12-lake-tarn-circuit

Survives alias changes if the ID is still present: 12-old-name parses to trail 12, is flagged tainted and gets one 301 to the current alias. No alias index needed.

Alias only (a Remove IDs from URLs option, as in com_content since 4.0)

/trails/lake-tarn-circuit

Prettier, but the alias must be unique within the parent (the category) and the table needs an index on alias. Changing an alias breaks old links unless a redirect exists.

ID only

/trails/12

Unambiguous, but poor for SEO. Avoid.

The id:alias form of the query value (12:lake-tarn-circuit) is called a slug. Layouts should build slugs once, either in SQL or in PHP ($item->id . ':' . $item->alias), and hand the slug to the route helper. PreprocessRules repairs the calls that forget it.

Aliases themselves must be URL-safe. The component’s table class generates them in check()/store() through ApplicationHelper::stringURLSafe() (or OutputFilter::stringURLUnicodeSlug() when Unicode Aliases is on) and refuses duplicates inside the same category; the rules are in Component MVC: the Administrator Side.

The nestable configuration is for tree-shaped views such as categories. com_content declares setNestable() on category and the router returns the full category path from getCategorySegment(), so that Trails › Alpine becomes the two segments trails and alpine below the menu item. StandardRules then drops the segments the menu item already contains. com_tallpine stays with the flat list/detail pair; a category tree would add categories and category views in front of trail, with $trail->setKey('id')->setParent($category, 'catid').

Tainted URLs (5.3 and later)

Before Joomla 5.3 a router could answer only "parsed" or "not parsed"; a wrong alias next to a valid ID, a missing suffix or an ID-only URL were either accepted silently (duplicate content) or rejected. Since 5.3 the router can parse such a URL and flag it with Router::setTainted(). Code that runs afterwards checks isTainted(); the System - SEF plugin builds the canonical URL and issues a single 301 redirect. The component router in the example calls $this->app->getRouter()->setTainted() in getTrailId() whenever the segment is not exactly id-alias or alias. Doing the redirect in the router would stop later parsing in the same request and could chain redirects, which is why only the flag is set. Older books and tutorials show routers that redirect themselves, or accept any alias; on 5.3 and later set the flag instead.

Language, associations and the page cache

On a multilingual site MenuRules builds the lookup for the language of the query (lang) and for * (all languages). A menu item for a specific language overrides an All item. A route helper therefore passes &lang=de-DE for records with a specific language, and only when Multilanguage::isEnabled(). The language filter plugin then adds the language prefix. Language associations (the link to the German version of the same trail) are produced by the component’s association helper and call the route helper per language; see Multilingual Sites.

Route::_() and route helpers

Route::_($url, $xhtml = true, $tls = Route::TLS_IGNORE, $absolute = false) converts an internal URL into the public URL. The constants are TLS_IGNORE, TLS_FORCE and TLS_DISABLE. Route::link($client, $url, ...) does the same for another application client ('administrator', 'site', 'api'), which is what an administrator-side component, a CLI command or a scheduled task uses to build a link to the front end. Without SEF it only completes the URL.

<?php

namespace Tallpine\Component\Tallpine\Site\Helper;

use Joomla\CMS\Language\Multilanguage;

\defined('_JEXEC') or die;

abstract class RouteHelper
{
    /** Link to the list of trails, optionally filtered by category. */
    public static function getTrailsRoute(int $catid = 0, ?string $language = null): string
    {
        $link = 'index.php?option=com_tallpine&view=trails';

        if ($catid > 1) {
            $link .= '&catid=' . $catid;
        }

        return $link . self::languageSuffix($language);
    }

    /** Link to one trail. $id may be "12" or the slug "12:lake-tarn-circuit". */
    public static function getTrailRoute(int|string $id, int $catid = 0, ?string $language = null): string
    {
        $link = 'index.php?option=com_tallpine&view=trail&id=' . $id;

        if ($catid > 1) {
            $link .= '&catid=' . $catid;
        }

        return $link . self::languageSuffix($language);
    }

    private static function languageSuffix(?string $language): string
    {
        return $language && $language !== '*' && Multilanguage::isEnabled() ? '&lang=' . $language : '';
    }
}

Rules for using it:

  • Always pass the result of the helper through Route::_() in the layout. The helper returns an internal URL on purpose, so that the same value can be stored (return URLs, notifications) and routed later.

  • $xhtml = true (the default) escapes & as & for use in an href. Pass false when the URL goes into a redirect, JSON or a Uri object, or the & will be double-escaped.

  • Route::_() can only produce a canonical URL if the query contains everything the router needs. Passing catid with the trail lets a category-aware router choose the right menu item; passing the slug avoids a database lookup.

  • Do not concatenate paths yourself ('/trails/' . $id): a relocated menu item, a changed suffix setting or the language prefix would break the link.

  • A link inside a cached page must not depend on the current user; the page cache stores the generated HTML.

A controller redirect uses the same helper: $this->setRedirect(Route::_(RouteHelper::getTrailsRoute(), false)). A redirect to a URL that has query data from a form should use false for $xhtml.

Pagination

The trail list can contain hundreds of rows, so it is paginated. The pieces:

  1. The model state holds list.limit and list.start. ListModel::populateState() reads the limit from the request variable limit (remembered in the user state global.list.limit, defaulting to the Default List Limit of Global Configuration) and the offset from limitstart. The offset is rounded down to a multiple of the limit.

  2. getItems() applies setQuery($query, $start, $limit), and getPagination() creates a Joomla\CMS\Pagination\Pagination from the total number of rows, the start and the limit.

  3. The layout prints getPagesLinks() (the page list), optionally getPagesCounter() ("Page 2 of 5") and getLimitBox() (a select that sets limit), as in the layout above.

The pagination links are produced by Route::_(), with limitstart added to the query of the current page. The site router maps limitstart to a neutral URL variable start on the public side and back on parse (/trails?start=20), which is why the SEF URL of page two still reaches the model as limitstart. Properties and methods worth knowing:

Member Use

$pagination->pagesTotal

number of pages; show the controls only when it is greater than 1

$pagination->hideEmptyLimitstart = true

omit limitstart=0 from the first page’s link so that page one has the canonical URL (the first page must not exist at two URLs)

$pagination->setAdditionalUrlParam('filter', $value)

keep a filter in every page link

getPagesLinks()

accessible nav markup built from the joomla.pagination layouts (overridable)

getLimitBox()

the select used with show_pagination_limit-style options

getPagesCounter(), getResultsCounter()

"Page 2 of 5" and "Results 21 - 40 of 87"

Pitfalls:

  • A list whose pagination links change with every request (a random order, an unstable sort without a tiebreaker) shows duplicates or skips rows between pages. Order by a unique column last.

  • A filter that is applied from the user state but is not in $pagination->setAdditionalUrlParam() or in the cache $urlparams list shows the wrong page to the second visitor.

  • limit=0 means "all". Cap it with max in the menu item form or in populateState() if the table is large, because visitors can pass limit=0 themselves; the cap in the example’s trails_per_page only controls the default.

  • Search engines should not index every ?start= page of a filtered list; use the menu item’s Robots setting or a canonical link for filtered views.

Assets for the site side

Component assets are registered in media/com_tallpine/joomla.asset.json and loaded through the Web Asset Manager; the view asks for them by name, as in the view above. Do not echo <script> or <link> tags in a layout and do not use HTMLHelper::_('script') for new code. The manifest copies media with <media destination="com_tallpine" folder="media">.

{
  "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
  "name": "com_tallpine",
  "version": "1.0.0",
  "description": "Assets of com_tallpine",
  "license": "GPL-2.0-or-later",
  "assets": [
    {
      "name": "com_tallpine.trails",
      "type": "style",
      "uri": "com_tallpine/trails.css",
      "weight": 10
    },
    {
      "name": "com_tallpine.trail-filter",
      "type": "script",
      "uri": "com_tallpine/trail-filter.js",
      "dependencies": ["core"],
      "attributes": { "type": "module", "defer": true }
    },
    {
      "name": "com_tallpine.trails-data",
      "type": "preset",
      "dependencies": ["com_tallpine.trails#style", "com_tallpine.trail-filter#script"]
    }
  ]
}

Site-side specifics:

  • The registry of the active component is loaded automatically from its media/<component>/joomla.asset.json; a module or plugin that wants its own assets calls addRegistryFile() itself. Registries are merged in a fixed order: media/vendor, media/system, media/legacy, then the component, then the template, so a template can override a component asset by registering one with the same name. Details are in Web Asset Manager.

  • Per-page data for scripts goes through $wa->addInlineScript() or, better, $document->addScriptOptions() (script options keyed com_tallpine.trails) read with Joomla.getOptions() in the script. Inline script strings that contain user data need escaping for JavaScript, not HTML.

  • Since Joomla 6.1 an asset’s "version" may be "nocache" to disable cache busting by version in development (see the Web Asset Manager page for the other values).

  • ES5 assets were removed from core in Joomla 5.0, so scripts of new extensions are ES2015+ modules; the "defer" or "type": "module" attributes avoid blocking rendering.

Language strings are loaded by the dispatcher from components/com_tallpine/language/en-GB/com_tallpine.ini, and the strings used by JavaScript are registered with Text::script('COM_TALLPINE_TRAIL_FILTER_EMPTY') in the view.

Metadata, structured data and accessibility on site views

  • Set the document title and description in the view (see above) and let the menu item Metadata tab override them.

  • Add <link rel="canonical"> only through the document API (for example $document->addHeadLink(Route::_(..., true, Route::TLS_IGNORE, true), 'canonical')), so that the absolute, tainted-corrected URL is used. The System - SEF plugin already adds canonical links for its own cases; check the page source before adding a duplicate.

  • Schema.org output for a custom entity is a plugin and form-field matter; see SEO: SEF URLs, Redirects and Schema.org.

  • Layouts are written to the accessibility rules the core layouts follow: a caption or heading for each table, scope attributes, nav with an aria-label for pagination (the core pagination layouts do this), and text alternatives for images. Colour contrast and RTL support are template matters; see Colour Modes, RTL and Accessibility.

The Tallpine trail list and detail, assembled

The detail layout, with the access-controlled "current conditions" block for the Trail Warden group, ties the pieces together. Conditions are a custom field in the scenario, so the layout uses the fields API result that the model or a content plugin prepared:

<?php

use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Tallpine\Component\Tallpine\Site\Helper\RouteHelper;

\defined('_JEXEC') or die;
?>
<article class="com-tallpine-trail">
    <h1><?php echo $this->escape($this->item->title); ?></h1>

    <dl class="com-tallpine-trail__facts">
        <dt><?php echo Text::_('COM_TALLPINE_FIELD_DISTANCE_KM'); ?></dt>
        <dd><?php echo (float) $this->item->distance_km; ?> km</dd>
        <dt><?php echo Text::_('COM_TALLPINE_FIELD_ELEVATION_GAIN'); ?></dt>
        <dd><?php echo (int) $this->item->elevation_gain; ?> m</dd>
        <dt><?php echo Text::_('COM_TALLPINE_FIELD_DIFFICULTY'); ?></dt>
        <dd><?php echo $this->escape($this->item->difficulty); ?></dd>
    </dl>

    <?php echo $this->item->description; // filtered by the text filter when it was saved ?>

    <p>
        <a href="<?php echo Route::_(RouteHelper::getTrailsRoute((int) $this->item->catid)); ?>">
            <?php echo Text::_('COM_TALLPINE_BACK_TO_TRAILS'); ?>
        </a>
    </p>
</article>

Custom fields assigned to the component’s trail context are rendered by the Fields system plugin after onContentPrepare has run on the item’s text; wiring a component into that pipeline is described in Categories, Custom Fields and Workflow Integration and from the editor’s side in Custom Fields. Member-only conditions are decided by the view level of the field or of the trail, never by a ?> check in the layout alone; see ACL in Extensions.

Testing and debugging routing

  • After changing a router, clear the page cache and the Menu cache: the component router builds its lookup from menu items on first use.

  • Compare the generated link and the parsed result: in a layout, echo Route::_('index.php?option=com_tallpine&view=trail&id=12'); from the shell, curl -sI shows the status line and Location of a tainted-URL redirect:

curl -sI http://localhost:8080/trails/12-lake-tarn-circuit.html | head -n 1     # 200 OK
curl -sI http://localhost:8080/trails/12-wrong-alias.html | grep -iE '^(HTTP|location)'   # 301 to the canonical URL
curl -sI http://localhost:8080/trails/99999-no-such-trail.html | head -n 1       # 404 Not Found
  • The Debug system plugin (enabled with Debug System in System > Global Configuration > System) shows the queries run by the router; a router that issues one query per link in a long list is a performance problem (the com_content router caches category lookups for exactly that reason). See Debugging.

  • Cross-check three cases for every view: with a menu item, without a menu item, and with a menu item for a different language. Most routing bugs appear only in the second and third.

  • Core system tests use Cypress, not Codeception, as older books say; for the coding style and static analysis of router classes see Coding Standards and Static Analysis.

Common errors and pitfalls

  • Links 404 after enabling "Remove IDs": the alias is not unique within the category, or the table has no alias index. The parse callback returns the first match; make the lookup depend on the category from $query.

  • 404 for every URL below a menu item: the router class is not named Router in Site\Service, the getTrailId() method returns null instead of false/an ID, or the callback name does not match the view name (getTrailsId vs getTrailId).

  • The URL contains /component/tallpine/...: no published menu item of the component matches the view and language. Create one, or accept NomenuRules URLs for deliberately unlisted views.

  • The wrong menu item is used for the link: two menu items have the same view with different catid; MenuRules choose by the key in the menu item link, so the item must carry the matching request variable.

  • Duplicate content: both /trails/12 and /trails/12-lake-tarn-circuit answer 200. Call setTainted() for any non-canonical form so the SEF plugin redirects it.

  • A layout works in the browser but breaks after saving as an override: overrides copy the whole file; after a component update compare it in the Template Manager.

  • Unescaped output: any echo $item->title without escape() is an XSS bug.

  • Forgetting the whitelist: a $urlparams list that omits limitstart makes the page cache serve page one for every page.

  • Caching per user: a view that shows member-only data must set $cachable = false for logged-in users.

  • Using removed or deprecated APIs: Factory::getDbo() and Factory::getUser() (use the Aware traits and getCurrentUser()), $this->get('Items') (use the model directly, deprecated since 5.3, removed in 7.0), Table::getInstance() and JFactory-style aliases (available in 6.x only with the Behaviour - Backward Compatibility 6 plugin). See Error Handling and Legacy APIs.

Security considerations

  • Treat every URL segment, id, catid, limit and limitstart as hostile input. Cast to integers, bind parameters, whitelist ordering columns.

  • Enforce access in the query (whereIn() on view levels) and, for single-record views, again in the view or model. Do not rely on hiding the link.

  • Deny access with the same status for "does not exist" and "not allowed" if existence itself is sensitive.

  • Escape output in layouts. HTML fields are filtered on input by text filters; do not disable filtering for convenience.

  • Never put state-changing actions behind a GET link; forms that change data include the form token (HTMLHelper::_('form.token')) and check it with $this->checkToken() in the controller.

  • The tainted-URL redirect only targets URLs the router itself builds, so it cannot be turned into an open redirect by passing a foreign URL in a segment.

Version notes and book corrections

  • RouterView and RouterViewConfiguration are marked @since 3.5 in the source (the rules classes @since 3.4), and the component router factory and RouterServiceInterface since 4.0. Routers written as router.php function files (BuildRoute/ParseRoute) belong to the Joomla 3 style; write new routers as RouterView classes.

  • com_content reads a sef_ids component option (labelled Remove IDs from URLs) in its router; the Tallpine router reuses the same idea with its own sef_ids option in config.xml.

  • Joomla 5.3 added Router::setTainted()/isTainted() and the single 301 in the SEF plugin.

  • Joomla 5.3 deprecated the view get() forwarding (removed in 7.0). Joomla 6.0 removed a number of deprecated classes, most of them available again only through Behaviour - Backward Compatibility 6; 6.0 also made AdminModel::getItem() return stdClass, which matters to the administrator, not the site, models in this page.

  • Older books such as Developing Extensions for Joomla! 5 (2023) cover routers well, with RouterView and the three rules, but predate the tainted-URL mechanism of 5.3 and the deprecation of $this->get() in views; Joomla! 4 Masterclass (2023) explains menu item types and SEF settings from the site builder’s view but not the router API, and targets Joomla 4, which reached end of life in 2025-10. Wiki pages on docs.joomla.org about "Creating a component router" describe the pre-4.0 function-based routers and are marked obsolete; the Developer Manual (manual.joomla.org) is the maintained source.