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 |
|---|---|
|
|
|
|
|
|
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.
Source: Joomla Manual: Routing
From request to layout: the site dispatch chain
The steps in order:
-
The
SiteRouterparses the path (see The SEF router for a component), setsoption,view,idandItemid, makes the matching menu item active and merges the menu item’s request variables. -
The
SiteApplicationboots the component (bootComponent('com_tallpine')) and asks it for a dispatcher. A component without its ownDispatcherclass gets the genericComponentDispatcher, which loads the component language file, runscheckAccess()and picks the controller from thetask(defaultdisplay) or thecontrollerrequest value. -
The
DisplayControllerinstantiates the view named by theviewrequest variable and the model of the same name, assigns the model to the view and callsdisplay(). -
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’shtml/com_tallpine/trails/default.phpoverride first, thencomponents/com_tallpine/tmpl/trails/default.php. The override mechanism is in Template Overrides and Alternative Layouts. -
The rendered output replaces
<jdoc:include type="component" />in the templateindex.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;
}
}
Source: Joomla Manual: MVC factory and the ListModel source in joomla-cms, libraries/src/MVC/Model
The points that matter:
-
filter_fieldsis the whitelist of columns the list ordering may use.ListModelignores an ordering that is not in it, which is what stops a visitor from putting arbitrary SQL intofilter_order. Never buildORDER BYfrom a request value without such a whitelist. -
whereIn()with the user’s authorised view levels is the site access check.getCurrentUser()comes from theCurrentUserTraitthat 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()(theDatabaseAwareTrait);Factory::getDbo()is not used. Values are never concatenated:bind()with aParameterTypeandwhereIn()produce prepared statements. See Database API. -
createQuery()is the Joomla 5/6 spelling of the oldergetQuery(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'));
}
}
Source: Joomla Manual: MVC factory
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');
}
}
Source: Joomla Manual: Web Asset Manager
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 olderFactory::getDocument()calls in views. -
The
GenericDataExceptionbranch is the standard way a view surfaces model errors from the oldsetError()mechanism. New models should throw exceptions; core drops thesetError()support in 7.0. -
The base
HtmlViewoffersgetDocument()andgetCurrentUser()but no application getter, so core site views callFactory::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
paginationobject is created byListModel::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);
}
}
Source: Joomla Manual: Web Asset Manager
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>
Source: Joomla Manual: Layouts
$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>
Source: Joomla Manual: Forms and fields and the layout XML files in joomla-cms, components/com_content/tmpl
How the pieces map to stored data:
-
<layout title option>:titleis a language constant for the type’s name in the selection dialog;optionis the label of the type’s entry in the menu item’s Link field. Both constants belong in the administrator-sidecom_tallpine.sys.inifile, because the dialog is rendered in the administrator application; see Language Files and Internationalisation. -
<fields name="request">: values here are written into thelinkcolumn of the menu item (index.php?option=com_tallpine&view=trails&catid=8). They are request variables, so the router puts them in$queryandMenuRulescompare them when it searches the menu item that best matches a URL being built. A menu item with a differentcatidthan the one in the URL is not a match. -
<fields name="params">: values are stored as JSON in the menu item’sparamscolumn and merged into the component parameters, so$app->getParams()(which the models above read throughstate params) returns the menu item value when one is set and the component default otherwise. Any field can offeruseglobal="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;requestfields 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.
Source: Joomla Manual: RouterView
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 |
|---|---|
|
the view name, equal to the folder in |
|
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, |
|
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 |
|
the view’s items form a tree (categories): a URL contains one segment for each level |
|
registers an additional layout that menu items of this view may use; the default layout is |
|
the reverse of |
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.
Source: Joomla Manual: RouterView
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
Routerin theSite\Servicenamespace to be found byRouterFactory, and it must extendRouterView(or implementRouterInterfacewithpreprocess(),build()andparse()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 thatTallpine\Component\Tallpine\Site\Service\Routerbelongs tocom_tallpine. Keep the namespace and the foldercom_tallpinealigned. -
The callbacks are found by name: for a view
trailthe router callsgetTrailSegmentandgetTrailId. The pattern isget<Viewname>Segmentandget<Viewname>Idwith the view name capitalised byucfirst(). 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 => segmentpairs;StandardRuleslater picks the segments it needs and replaces:with-, which is why12:lake-tarn-circuitappears as12-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 usesbind(). 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 |
|---|---|---|
|
preprocess |
Optional helper for a view with a database table. When the query has |
|
preprocess |
Chooses the |
|
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 |
|
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 |
|
A Single Trail menu item for trail 12 exists (alias |
|
No menu item of |
|
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
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
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) |
|
Survives alias changes if the ID is still present: |
Alias only (a Remove IDs from URLs option, as in |
|
Prettier, but the alias must be unique within the parent (the category) and the table needs an index on |
ID only |
|
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.
Source: Joomla Manual: Routing
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 (returnURLs, notifications) and routed later. -
$xhtml = true(the default) escapes&as&for use in anhref. Passfalsewhen the URL goes into a redirect, JSON or aUriobject, or the&will be double-escaped. -
Route::_()can only produce a canonical URL if the query contains everything the router needs. Passingcatidwith 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:
-
The model state holds
list.limitandlist.start.ListModel::populateState()reads the limit from the request variablelimit(remembered in the user stateglobal.list.limit, defaulting to the Default List Limit of Global Configuration) and the offset fromlimitstart. The offset is rounded down to a multiple of the limit. -
getItems()appliessetQuery($query, $start, $limit), andgetPagination()creates aJoomla\CMS\Pagination\Paginationfrom the total number of rows, the start and the limit. -
The layout prints
getPagesLinks()(the page list), optionallygetPagesCounter()("Page 2 of 5") andgetLimitBox()(a select that setslimit), 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 |
|---|---|
|
number of pages; show the controls only when it is greater than 1 |
|
omit |
|
keep a filter in every page link |
|
accessible |
|
the select used with |
|
"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$urlparamslist shows the wrong page to the second visitor. -
limit=0means "all". Cap it withmaxin the menu item form or inpopulateState()if the table is large, because visitors can passlimit=0themselves; the cap in the example’strails_per_pageonly 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.
Source: joomla-cms: Pagination.php
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"]
}
]
}
Source: Joomla Manual: Web Asset Manager
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 callsaddRegistryFile()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 keyedcom_tallpine.trails) read withJoomla.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
captionor heading for each table,scopeattributes,navwith anaria-labelfor 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>
Source: Joomla Manual: Layouts
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 -sIshows the status line andLocationof 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
Source: Joomla Manual: Routing
-
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_contentrouter 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
aliasindex. 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
RouterinSite\Service, thegetTrailId()method returnsnullinstead offalse/an ID, or the callback name does not match the view name (getTrailsIdvsgetTrailId). -
The URL contains
/component/tallpine/...: no published menu item of the component matches the view and language. Create one, or acceptNomenuRulesURLs for deliberately unlisted views. -
The wrong menu item is used for the link: two menu items have the same view with different
catid;MenuRuleschoose by the key in the menu item link, so the item must carry the matching request variable. -
Duplicate content: both
/trails/12and/trails/12-lake-tarn-circuitanswer 200. CallsetTainted()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->titlewithoutescape()is an XSS bug. -
Forgetting the whitelist: a
$urlparamslist that omitslimitstartmakes the page cache serve page one for every page. -
Caching per user: a view that shows member-only data must set
$cachable = falsefor logged-in users. -
Using removed or deprecated APIs:
Factory::getDbo()andFactory::getUser()(use the Aware traits andgetCurrentUser()),$this->get('Items')(use the model directly, deprecated since 5.3, removed in 7.0),Table::getInstance()andJFactory-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,limitandlimitstartas 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
-
RouterViewandRouterViewConfigurationare marked@since 3.5in the source (the rules classes@since 3.4), and the component router factory andRouterServiceInterfacesince 4.0. Routers written asrouter.phpfunction files (BuildRoute/ParseRoute) belong to the Joomla 3 style; write new routers asRouterViewclasses. -
com_contentreads asef_idscomponent option (labelled Remove IDs from URLs) in its router; the Tallpine router reuses the same idea with its ownsef_idsoption inconfig.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 madeAdminModel::getItem()returnstdClass, 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
RouterViewand 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.