Component MVC: The Administrator Side
|
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. |
This page follows the administrator side of a component from the first request to the redirect that ends it. It was
written against Joomla 6.2.x and PHP 8.3+ and checked on 2026-10-30 against the Joomla Developer Manual and the
6.2-dev source tree of joomla-cms. The running example is the trail entity of com_tallpine
(namespace Tallpine\Component\Tallpine) from the Tallpine Hiking Club site: a list of trails with search tools and
a form to create and edit one trail. The component is a part of the package pkg_tallpine; packaging is covered in
Extension Types, Manifests and Packaging, and the site
(front-end) half in Component Site Views and Routing.
The audience is a developer who already knows PHP. Namespaces, Composer-style autoloading and exceptions are not re-taught here; see PHP namespaces and autoloading and PHP errors and exceptions. The dependency-injection container that wires everything together is explained in Namespaces, Autoloading and Dependency Injection; here it is used, not derived.
The big picture
A Joomla component is a namespaced MVC application that the CMS boots on demand. For the administrator request
index.php?option=com_tallpine&view=trails the CMS loads administrator/components/com_tallpine/services/provider.php,
asks the container for the component object (the Extension class), and calls its getDispatcher(). The Dispatcher
checks that the user may manage the component, works out the controller and task from the request, executes the task and
finally performs the redirect, if the controller set one. The controller obtains a model and a view from the
MVCFactory, the model gets its Table from the same factory, and the view renders a layout from the tmpl folder.
| Piece | Class (Tallpine example) | Role |
|---|---|---|
Service provider |
|
Registers the MVCFactory, the dispatcher factory, the router factory and the component object in the container. |
Extension class |
|
The object the container returns for |
Dispatcher |
|
Optional. Extends |
Controller |
|
Reads the request, checks the token and permissions, calls the model, sets a message and a redirect. |
Model |
|
Builds the list query and filters, loads, validates and stores one record, handles check-in and check-out. |
Table |
|
Row object for |
View |
|
Collects data from the model, builds the toolbar, renders |
The Manual page on the MVC pattern describes the same flow in generic terms: BaseController::execute() runs the method
named by the second half of the task parameter, the controller creates the view and the model, and the model creates
the table.
Source: Joomla Manual: MVC Overview
The request lifecycle in one picture
The diagram follows a complete edit cycle of a trail: opening the form, saving it, and landing on the list again.
Local setup and the file layout
A component is easiest to build against a running site with the extension folder mounted into it. The Compose file from Local Development with Docker runs Joomla 6.2, MariaDB and Mailpit; mount the working copy of the component and install it with System > Install > Extensions > Install from Folder, or register it with the discover feature. Every change to the manifest (new files, new menu entries) needs a reinstall or a discover-and-install cycle; changes to existing PHP files are picked up at once.
Joomla 4 and later keep the source of a component in the standard layout below. Only the administrator half is shown.
com_tallpine/
tallpine.xml # manifest
administrator/components/com_tallpine/
access.xml # ACL actions
config.xml # component options
forms/
trail.xml # edit form
filter_trails.xml # search tools
language/en-GB/
com_tallpine.ini
com_tallpine.sys.ini
services/provider.php
sql/
install.mysql.utf8.sql
install.postgresql.sql
updates/mysql/1.0.0.sql
src/
Controller/{DisplayController,TrailController,TrailsController}.php
Dispatcher/Dispatcher.php
Extension/TallpineComponent.php
Model/{TrailModel,TrailsModel}.php
Table/TrailTable.php
View/Trails/HtmlView.php
View/Trail/HtmlView.php
tmpl/
trails/default.php
trail/edit.php
The namespace is declared in the manifest and mapped to the folders by the CMS autoloader, so there are no
require statements. The Administrator segment maps to administrator/components/com_tallpine/src, the Site
segment to components/com_tallpine/src and the Api segment to api/components/com_tallpine/src.
<?xml version="1.0" encoding="utf-8"?>
<extension type="component" method="upgrade">
<name>com_tallpine</name>
<author>Tallpine Hiking Club</author>
<creationDate>2026-10</creationDate>
<version>1.0.0</version>
<description>COM_TALLPINE_XML_DESCRIPTION</description>
<namespace path="src">Tallpine\Component\Tallpine</namespace>
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.utf8.sql</file>
<file driver="postgresql" charset="utf8">sql/install.postgresql.sql</file>
</sql>
</install>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
<schemapath type="postgresql">sql/updates/postgresql</schemapath>
</schemas>
</update>
<administration>
<menu img="class:map-marked-alt">COM_TALLPINE</menu>
<submenu>
<menu link="option=com_tallpine&view=trails">COM_TALLPINE_TRAILS</menu>
</submenu>
<files folder="administrator/components/com_tallpine">
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>forms</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="administrator/components/com_tallpine/language">
<language tag="en-GB">en-GB/com_tallpine.ini</language>
<language tag="en-GB">en-GB/com_tallpine.sys.ini</language>
</languages>
</administration>
</extension>
The method="upgrade" attribute makes a reinstall behave as an update, so existing data survives. The <namespace
path="src"> element carries the prefix; the folder inside each client area is always src. A new folder that is not
listed in <files> is simply not copied to the site, which is the most common reason for "class not found" after the
first installation of a freshly added controller folder. The SQL files, update scripts and the installer script class are
covered in Installer Scripts and SQL Updates, and the
language files in Language Files and Internationalisation.
The service provider and the Extension class
services/provider.php
Joomla 4 replaced the old entry-point file (tallpine.php that called Factory::getApplication()->bootComponent()
by hand) with a service provider. The file returns an anonymous class implementing ServiceProviderInterface, and the
CMS includes it the first time the component is needed. The built-in providers keep the file short: MVCFactory creates
the factory that instantiates controllers, models, views and tables from the namespace, ComponentDispatcherFactory
creates the dispatcher, RouterFactory the SEF router and CategoryFactory the category service.
<?php
/**
* @package Tallpine.Administrator
* @subpackage com_tallpine
*
* @copyright (C) 2026 Tallpine Hiking Club
* @license GNU General Public License version 2 or later
*/
\defined('_JEXEC') or die;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\CategoryFactory;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
use Joomla\CMS\HTML\Registry;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Tallpine\Component\Tallpine\Administrator\Extension\TallpineComponent;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->registerServiceProvider(new CategoryFactory('\\Tallpine\\Component\\Tallpine'));
$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->setRegistry($container->get(Registry::class));
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
$component->setCategoryFactory($container->get(CategoryFactoryInterface::class));
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
return $component;
}
);
}
};
Source: Joomla Manual: The MVC Factory Class; the layout of the provider follows the com_banners provider in joomla-cms 6.2-dev
The provider lives in the administrator folder of the component, and the component object it builds serves the
administrator, site and API clients alike. The strings passed to the factories are the namespace prefix without the
client segment. The factory appends Administrator, Site or Api itself, which is why one MVCFactory instance can
build an administrator model from site code, and why the dispatcher factory looks for
Tallpine\Component\Tallpine\Administrator\Dispatcher\Dispatcher or its Site twin.
The Extension class
The Extension class is the object the container returns for ComponentInterface. It extends MVCComponent and is
where capabilities are declared by implementing interfaces: BootableExtensionInterface for a boot() hook that runs
before the first dispatch (register an HTML helper, a class loader or an event listener),
CategoryServiceInterface for category counting, RouterServiceInterface for the SEF router and TagServiceInterface
for tagging. The traits shipped with the CMS implement the interfaces, so the class is mostly declarations.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Extension;
\defined('_JEXEC') or die;
use Joomla\CMS\Categories\CategoryServiceInterface;
use Joomla\CMS\Categories\CategoryServiceTrait;
use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Extension\BootableExtensionInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\HTML\HTMLRegistryAwareTrait;
use Psr\Container\ContainerInterface;
class TallpineComponent extends MVCComponent implements
BootableExtensionInterface,
CategoryServiceInterface,
RouterServiceInterface
{
use HTMLRegistryAwareTrait;
use RouterServiceTrait;
use CategoryServiceTrait;
public function boot(ContainerInterface $container): void
{
// Register services that must exist before the first controller runs,
// for example an HTML helper: $this->getRegistry()->register('trail', new Trail());
}
protected function getTableNameForSection(?string $section = null): string
{
return 'tallpine_trails';
}
}
A component that needs neither categories nor a router can drop the interfaces. getTableNameForSection() is the hook
the category service uses to count items per category; categories, custom fields and workflow integration for
com_tallpine are in
Categories, Custom Fields and Workflow Integration.
Dependencies reach the component through the container and setters, not through static calls. Inside controllers,
models and views the same rule holds: the application, the database, the current user and the document are injected or
read from the aware traits. Older books such as Developing Extensions for Joomla! 5 (2023) still show
Factory::getLanguage() and Factory::getContainer()->get('DatabaseDriver') in places; in 6.2 prefer
$this->getDatabase() in models and tables, $this->getDocument() and $this->getCurrentUser() in views, and
$this->app or getApplication() in controllers. Factory::getDbo() is gone from new code (see
Database API).
The Dispatcher
The default Joomla\CMS\Dispatcher\ComponentDispatcher is enough for most components, and ComponentDispatcherFactory
falls back to it when the component has no Dispatcher class of its own. Its dispatch() method does four things in
this order:
-
calls
checkAccess(), which in the administrator client throws aNotAllowedexception (HTTP 403) unless the user hascore.manageon the component; -
reads the
taskparameter (defaultdisplay) and, when it contains a dot, splits it into controller and task:trail.saverunsexecute('save')onTrailController; -
creates the controller through the MVCFactory, passing the client (
AdministratororSite) and the request input; -
calls
$controller->execute($task)and then$controller->redirect().
The constructor also loads the component language files (loadLanguage()), first from JPATH_BASE and then from the
component folder. When no task is given, the controller is DisplayController and the task display, which is why
every component needs a DisplayController.
A custom Dispatcher is useful for an extra access rule that must run before any controller, for loading assets or for a
component that answers the same task in a different way. The following one requires, in addition to core.manage, a
membership of the Tallpine Trail Warden group for the destructive tasks.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Dispatcher;
\defined('_JEXEC') or die;
use Joomla\CMS\Access\Exception\NotAllowed;
use Joomla\CMS\Dispatcher\ComponentDispatcher;
use Joomla\CMS\Language\Text;
class Dispatcher extends ComponentDispatcher
{
protected function checkAccess(): void
{
parent::checkAccess();
$task = $this->input->getCmd('task', 'display');
// Deleting trails is restricted by the component ACL action "core.delete",
// but an early, cheap rejection avoids creating controllers for forged requests.
if (str_ends_with($task, '.delete') && !$this->app->getIdentity()->authorise('core.delete', 'com_tallpine')) {
throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
}
The access check on the dispatcher is a first line only. Every controller task repeats the permission check for the
specific record (allowEdit(), canDelete(), canEditState()), because direct task URLs reach the controller with
any identifier. The action names come from access.xml and are covered in
ACL in Extensions. Authorisation is never enforced only by hiding a toolbar
button.
Controllers
Three base classes cover almost every administrator component. All three extend BaseController and receive the
application and the request input through the constructor ($this->app and $this->input).
DisplayController (BaseController)
BaseController::display() builds the view and the model named by the view parameter, attaches the model to the view
and calls the view’s display(). The edit view is protected from direct access: a request for layout=edit without a
previous task=trail.edit has no edit ID held in the user state, and the FormController helpers redirect it back to
the list with an error. A DisplayController therefore stays tiny.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Controller;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
/**
* The default view for the display method.
*
* @var string
*/
protected $default_view = 'trails';
}
Without $default_view the request index.php?option=com_tallpine would look for a view named after the component.
display() also takes $cachable and $urlparams; administrator views are never cached, so the defaults are right.
FormController: one record
FormController implements the whole life of one record. The edit-related tasks it registers are:
| Task | What it does |
|---|---|
|
Checks |
|
Checks |
|
One method: |
|
Checks the token, checks the record in, releases the held edit ID and redirects to the list. |
|
Runs the batch operations started from the Batch dialog on the list. |
The class names its views from the controller: TrailController has the single view trail (the item view) and the
plural trails (the list view), computed with an inflector. A controller with an irregular plural sets
$view_list explicitly. The text prefix selects the language keys of the success messages
(COM_TALLPINE_TRAIL_SAVE_SUCCESS).
<?php
namespace Tallpine\Component\Tallpine\Administrator\Controller;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\Utilities\ArrayHelper;
class TrailController extends FormController
{
protected $text_prefix = 'COM_TALLPINE_TRAIL';
/**
* Records can be created only in categories where the user has "core.create".
*/
protected function allowAdd($data = [])
{
$categoryId = ArrayHelper::getValue($data, 'catid', $this->input->getInt('filter_category_id'), 'int');
if ($categoryId) {
return $this->app->getIdentity()->authorise('core.create', $this->option . '.category.' . $categoryId);
}
return parent::allowAdd($data);
}
/**
* Editing is decided by the category of the existing record.
*/
protected function allowEdit($data = [], $key = 'id')
{
$recordId = isset($data[$key]) ? (int) $data[$key] : 0;
if ($recordId) {
$categoryId = (int) $this->getModel()->getItem($recordId)->catid;
if ($categoryId) {
return $this->app->getIdentity()->authorise('core.edit', $this->option . '.category.' . $categoryId)
|| ($this->app->getIdentity()->authorise('core.edit.own', $this->option . '.category.' . $categoryId)
&& (int) $this->getModel()->getItem($recordId)->created_by === (int) $this->app->getIdentity()->id);
}
}
return parent::allowEdit($data, $key);
}
}
Source: Joomla Manual: Library MVC Classes; the overrides follow BannerController in joomla-cms 6.2-dev
FormController::getModel() passes ['ignore_request' => true] by default, so populateState() does not read the
request on POST. When the same model class serves form display and the POST, reading the whole request twice is wasted
work, and the stored state of the form (jform) is read in save(). Keep that default when overriding getModel().
AdminController: many records
AdminController implements the operations on a selection from a list: publish, unpublish, archive, trash
(all one method, publish(), switched by getTask()), delete, checkin, reorder/saveOrderAjax and duplicate.
It reads the selected identifiers from the cid[] request array, calls the matching method of the singular
model, sets a message with Text::plural() and redirects to the list. The class does not run batch operations; those go
to the FormController::batch() of the record controller, which is why TrailController is the one that needs a
batch() override.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Controller;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
class TrailsController extends AdminController
{
protected $text_prefix = 'COM_TALLPINE_TRAILS';
public function __construct($config = [], ?MVCFactoryInterface $factory = null, $app = null, $input = null)
{
parent::__construct($config, $factory, $app, $input);
// "closed" and "reopened" run the same method; getTask() tells them apart.
$this->registerTask('reopened', 'closed');
}
/**
* The list controller works on the singular model.
*/
public function getModel($name = 'Trail', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
/**
* Marks the selected trails as seasonally closed or reopens them.
*/
public function closed(): void
{
$this->checkToken();
$ids = array_filter((array) $this->input->get('cid', [], 'int'));
$value = $this->getTask() === 'closed' ? 1 : 0;
if (empty($ids)) {
$this->app->enqueueMessage(\Joomla\CMS\Language\Text::_('COM_TALLPINE_NO_TRAILS_SELECTED'), 'warning');
} else {
$model = $this->getModel();
if (!$model->close($ids, $value)) {
$this->app->enqueueMessage($model->getError(), 'warning');
} else {
$this->setMessage(\Joomla\CMS\Language\Text::plural('COM_TALLPINE_N_TRAILS_CLOSED', \count($ids)));
}
}
$this->setRedirect('index.php?option=com_tallpine&view=trails');
}
}
Source: Joomla Manual: Post-Redirect-Get Pattern; getModel() and registerTask() as in BannersController of joomla-cms 6.2-dev
Three habits come from this class:
-
The token check comes first.
checkToken()verifies the form token (HTMLHelper::_('form.token')in the layout or the toolbar’s own submission) and aborts with an error message otherwise. A task method that changes data without it is a CSRF hole. The built-in methods already call it. -
Input is filtered at the point of reading.
$this->input->get('cid', [], 'int')casts every element; the helperarray_filterdrops the zeros that filtering produces from garbage. -
The list controller uses the singular model. The plural
TrailsModelonly reads; writing is done byTrailModel(anAdminModel). That separation is also what lets theListModelstay free of form handling.
Models
The Manual’s table of "which class for which job" is worth memorising:
| Use case | Controller | Model |
|---|---|---|
Show a list with search tools |
|
|
Operations on selected rows |
|
|
Show an edit form |
|
|
Handle the POST of the edit form |
|
|
The base classes below are BaseDatabaseModel (table access through the factory and the shared state), ItemModel
(adds getStoreId() for caches) and ListModel; FormModel adds XML forms, validation and check-in, and AdminModel
adds create, update, delete, publish, batch and ordering.
Model state
Every model keeps a state bag: values that control what the model returns, such as the search string, the ordering and
the current page. getState() calls populateState() once, on first use, and a controller or a module may call
setState() before that. A model created with ignore_request => true skips the request-reading part of
populateState().
Since Joomla 5.0 the state is no longer a CMSObject. The base model uses the StateBehaviorTrait and a small
transitional class, Joomla\CMS\MVC\Model\State, which extends Joomla\Registry\Registry. The class is marked
deprecated since 5.0 and is scheduled for removal in 7.0, when Registry is used directly. The visible difference is
the default handling: get($key, $default) returns the default when the stored value is an empty value in Joomla 7,
whereas the 5.x and 6.x transitional class returns the empty value but raises a deprecation notice. Code should always
pass a meaningful default, and never type-hint the state with the State class.
// Reading state in a model: always give a default, and treat the value as untrusted text.
$search = (string) $this->getState('filter.search', '');
$published = $this->getState('filter.published', '');
$ordering = $this->getState('list.ordering', 'a.title');
ListModel: the list query
ListModel supplies pagination, the total count and the filter plumbing. The subclass writes getListQuery() and, for
anything filterable or sortable, the whitelist filter_fields. Only columns on that whitelist are accepted as an
ordering; a request for any other column falls back to the default, which is both a usability and a security feature,
because the ordering is concatenated into SQL.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Model;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
class TrailsModel extends ListModel
{
public function __construct($config = [], ?MVCFactoryInterface $factory = null)
{
if (empty($config['filter_fields'])) {
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'difficulty', 'a.difficulty',
'published', 'a.published',
'access', 'a.access',
'category_id', 'a.catid', 'category_title',
'checked_out', 'a.checked_out',
'ordering', 'a.ordering',
];
}
parent::__construct($config, $factory);
}
protected function populateState($ordering = 'a.title', $direction = 'asc')
{
// ListModel reads filter[...] and list[...] from the request and from the user state.
parent::populateState($ordering, $direction);
}
protected function getStoreId($id = '')
{
// Every state value that changes the result must be part of the cache key.
$id .= ':' . $this->getState('filter.search');
$id .= ':' . $this->getState('filter.published');
$id .= ':' . $this->getState('filter.difficulty');
$id .= ':' . $this->getState('filter.category_id');
return parent::getStoreId($id);
}
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->createQuery();
$query->select(
$db->quoteName(
['a.id', 'a.title', 'a.alias', 'a.distance_km', 'a.elevation_gain', 'a.difficulty',
'a.published', 'a.access', 'a.catid', 'a.checked_out', 'a.checked_out_time', 'a.ordering']
)
)
->select($db->quoteName('c.title', 'category_title'))
->select($db->quoteName('u.name', 'editor'))
->from($db->quoteName('#__tallpine_trails', 'a'))
->join('LEFT', $db->quoteName('#__categories', 'c'), $db->quoteName('c.id') . ' = ' . $db->quoteName('a.catid'))
->join('LEFT', $db->quoteName('#__users', 'u'), $db->quoteName('u.id') . ' = ' . $db->quoteName('a.checked_out'));
// Published state: "*" shows everything, "" shows published and unpublished.
$published = (string) $this->getState('filter.published', '');
if (is_numeric($published)) {
$state = (int) $published;
$query->where($db->quoteName('a.published') . ' = :state')->bind(':state', $state, ParameterType::INTEGER);
} elseif ($published === '') {
$query->whereIn($db->quoteName('a.published'), [0, 1]);
}
// Category filter.
if ($categoryId = (int) $this->getState('filter.category_id')) {
$query->where($db->quoteName('a.catid') . ' = :catid')->bind(':catid', $categoryId, ParameterType::INTEGER);
}
// Difficulty list filter.
if ($difficulty = (string) $this->getState('filter.difficulty', '')) {
$query->where($db->quoteName('a.difficulty') . ' = :difficulty')->bind(':difficulty', $difficulty);
}
// Free text: "id:12" finds one trail, anything else searches the title.
$search = trim((string) $this->getState('filter.search', ''));
if ($search !== '') {
if (stripos($search, 'id:') === 0) {
$id = (int) substr($search, 3);
$query->where($db->quoteName('a.id') . ' = :id')->bind(':id', $id, ParameterType::INTEGER);
} else {
$like = '%' . str_replace(' ', '%', $search) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')->bind(':search', $like);
}
}
// Ordering comes from the whitelisted list[fullordering]; escape() guards the identifier anyway.
$orderCol = $this->getState('list.ordering', 'a.title');
$orderDirn = $this->getState('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn));
return $query;
}
}
Notes on the query:
-
Values are always bound:
bind()for single values andwhereIn()for lists. Concatenating a request value into the string is the classic SQL-injection mistake of old tutorials. Identifiers go throughquoteName(). -
Pagination is not written by the query.
ListModel::getItems()addsLIMIT/OFFSETfromlist.startandlist.limit, andgetPagination()andgetTotal()use the same query. A count query is derived from it, so avoidGROUP BYconstructions that make the derived count wrong. -
getStoreId()is the cache key ofgetItems(). If a state value changes the result and is missing from the key, two calls with different filters in one request return the same cached rows, which shows up when a module and the component share one model. -
The Manual page shows
$this->state->get(...). The method form$this->getState(...)is preferable, since it guarantees thatpopulateState()has run.
AdminModel: one record
AdminModel extends FormModel, which is why a single class loads the form, validates the data, stores the record and
handles check-in. Two methods are mandatory: getForm(), which loads the XML form, and loadFormData(), which returns
the data to put into it.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Model;
\defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Table\Table;
class TrailModel extends AdminModel
{
public $typeAlias = 'com_tallpine.trail';
protected $text_prefix = 'COM_TALLPINE_TRAIL';
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm('com_tallpine.trail', 'trail', ['control' => 'jform', 'load_data' => $loadData]);
if (empty($form)) {
return false;
}
// Fields the user may not change are removed from the form AND from the saved data.
if (!$this->canEditState((object) $data)) {
$form->setFieldAttribute('published', 'disabled', 'true');
$form->setFieldAttribute('published', 'filter', 'unset');
}
return $form;
}
protected function loadFormData()
{
// The session holds what the user typed if validation failed on the previous POST.
$data = Factory::getApplication()->getUserState('com_tallpine.edit.trail.data', []);
if (empty($data)) {
$data = $this->getItem();
}
$this->preprocessData('com_tallpine.trail', $data);
return $data;
}
public function getTable($name = 'Trail', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
protected function canDelete($record)
{
if (empty($record->id) || (int) $record->published !== -2) {
return false;
}
return $this->getCurrentUser()->authorise('core.delete', 'com_tallpine.category.' . (int) $record->catid);
}
protected function canEditState($record)
{
$user = $this->getCurrentUser();
if (!empty($record->catid)) {
return $user->authorise('core.edit.state', 'com_tallpine.category.' . (int) $record->catid);
}
return parent::canEditState($record);
}
protected function prepareTable($table)
{
$table->title = htmlspecialchars_decode($table->title, ENT_QUOTES);
if (empty($table->id)) {
// New trails go to the end of the manual ordering.
$table->ordering = $this->getDatabase()
->setQuery($this->getDatabase()->createQuery()
->select('MAX(' . $this->getDatabase()->quoteName('ordering') . ')')
->from($this->getDatabase()->quoteName('#__tallpine_trails')))
->loadResult() + 1;
}
}
/**
* Marks trails as closed for the season. Used by TrailsController::closed().
*/
public function close(array &$pks, int $value = 1): bool
{
$table = $this->getTable();
foreach ($pks as $i => $pk) {
if (!$table->load($pk)) {
unset($pks[$i]);
continue;
}
if (!$this->canEditState($table)) {
unset($pks[$i]);
$this->setError(\Joomla\CMS\Language\Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'));
return false;
}
$table->closed = $value;
if (!$table->store()) {
$this->setError($table->getError());
return false;
}
}
return true;
}
}
Source: Joomla Manual: Component tutorial, Step 8 Using Higher-level MVC Classes; hooks as in BannerModel of joomla-cms 6.2-dev
The pieces AdminModel brings that the page relies on:
-
getForm()andloadForm()findforms/trail.xmlin the component’sformsfolder; the control namejformproduces fields namedjform[title]. The plugin eventonContentPrepareFormlets other extensions (custom fields, for instance) add fields to the form, andpreprocessForm()is the hook for a component’s own additions. -
loadFormData()returns the user-state data first.FormController::save()stores the invalid input undercom_tallpine.edit.trail.databefore redirecting back, so the user does not lose what was typed, and clears it after a successful save. Skipping that fallback is why some third-party forms "forget" input after a validation error. -
save($data)runs the table cycle (bind(),prepareTable(),check(),store()), firesonContentBeforeSaveandonContentAfterSaveand clears the component cache.delete(),publish(),reorder()andbatch*()are similar. -
validate($form, $data)(fromFormModel) filters and validates the submitted array against the form XML and returns the cleaned data, orfalse. The filtering rules of the form fields apply, which is why a field withfilter="unset"cannot be set by a forged POST.
getItem() returns stdClass in Joomla 6.0
AdminModel::getItem($pk = null) loads the table row, copies the table properties and returns the data. In Joomla 5.x
and earlier the return value was a CMSObject; since Joomla 6.0 it is a plain \stdClass (or false on failure).
If the row has a params column, the method decodes it with Registry and sets $item->params to an array. The
change has practical consequences for older code, including code in books written against 4.x and 5.0:
-
methods of
CMSObjectsuch as$item->get('title'),$item->set(),$item->setProperties(),$item->getProperties()and$item->getErrors()no longer exist on the result. Use property access ($item->title) or cast to an array; -
$item->paramsis an array, not aRegistry; where the older code called$item->params->get('x'), use(new Registry($item->params))->get('x'), or read the array; -
a missing property is now a plain PHP warning for an undefined property, not a silent
nullfromget(); -
code that extended the model and returned a
CMSObjectfrom its owngetItem()should return an object of the same type for consistency.
The same release stopped views from carrying a $this->db property; ask the model or use $this->getDatabase() of the
model. The backward-compatibility plugin of 6.0 (Behaviour - Backward Compatibility 6) restores several removed
classes; treat the behaviour of getItem() as a change to adapt to, not as something to switch back. See
Deprecations and Preparing for Joomla 7 and
Upgrading Joomla 5 to 6 for the complete list of removals.
Error reporting from models
The models inherit the setError()/getError() mechanism for compatibility, and Joomla 5.4 added
setUseExceptions(true) so that the same code path throws. ListView::display() turns it on for the list model. New
code should throw exceptions from model methods and let the controller or the error page handle them; the legacy error
methods are removed from core usage in 7.0 and the trait is scheduled for removal in 8.0. Details are in
Error Handling and Legacy APIs.
Tables
A Table subclass maps one database table to an object: public properties for the columns, plus load(), bind(),
check(), store(), delete(), publish(), checkOut(), checkIn() and reorder(). Joomla 4 and 5 constructed it
with Table::getInstance('Trail', 'TallpineTable'); that method has been deprecated since 4.3 and is removed in 7.0.
The Manual (MVC Factory page) says the same: instantiate the table through the MVCFactory ($this->getTable() inside
a model does so) or directly with new. The old pattern was shown in Joomla! 4 Masterclass (2023) and in the older
Developing Extensions for Joomla! 5 examples; do not copy it.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Table;
\defined('_JEXEC') or die;
use Joomla\CMS\Application\ApplicationHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Registry\Registry;
class TrailTable extends Table
{
/**
* Constructor; the MVCFactory passes the database and the event dispatcher.
*/
public function __construct(DatabaseInterface $db, ?DispatcherInterface $dispatcher = null)
{
$this->typeAlias = 'com_tallpine.trail';
parent::__construct('#__tallpine_trails', 'id', $db, $dispatcher);
// Extra columns are published by the generic publish() helper.
$this->setColumnAlias('published', 'published');
}
public function check(): bool
{
try {
parent::check();
} catch (\Exception $e) {
$this->setError($e->getMessage());
return false;
}
if (trim($this->title) === '') {
$this->setError(Text::_('COM_TALLPINE_ERROR_TITLE_REQUIRED'));
return false;
}
if (trim($this->alias) === '') {
$this->alias = $this->title;
}
$this->alias = ApplicationHelper::stringURLSafe($this->alias, $this->language ?? '');
if ((float) $this->distance_km < 0 || (int) $this->elevation_gain < 0) {
$this->setError(Text::_('COM_TALLPINE_ERROR_NEGATIVE_VALUE'));
return false;
}
return true;
}
public function bind($array, $ignore = '')
{
// The "seasons" checkboxes arrive as an array and are stored as JSON.
if (isset($array['seasons']) && \is_array($array['seasons'])) {
$array['seasons'] = (new Registry($array['seasons']))->toString();
}
return parent::bind($array, $ignore);
}
public function store($updateNulls = true)
{
// The alias must be unique inside the category; a copy gets a new one.
$table = new self($this->getDatabase(), $this->getDispatcher());
if ($table->load(['alias' => $this->alias, 'catid' => $this->catid]) && ($table->id != $this->id || $this->id == 0)) {
$this->setError(Text::_('COM_TALLPINE_ERROR_UNIQUE_ALIAS'));
return false;
}
return parent::store($updateNulls);
}
public function getTypeAlias(): string
{
return $this->typeAlias;
}
}
Rules for tables:
-
Create other tables with
new. A table is not given the MVCFactory, so it cannot callcreateTable(). The core tables writenew self($db, $this->getDispatcher())(seeBannerTable::store()) when they need a second row object, andnew ClientTable($db)for a different table class. -
Validate in
check(), not in the controller.check()runs on everystore()from any code path: the form, the web services API and the CLI. Validation only in the form XML or controller is bypassed by those other paths. -
Keep
store()free of output and redirects. A table returnsfalseand callssetError()(or throws) and lets the caller decide. -
Check in and out are columns, not a feature to opt into. A table that has both
checked_outandchecked_out_timetakes part in check-out, andTable::checkOut()andTable::checkIn()read them. -
Assets. A table that is tracked by the ACL overrides
_getAssetName(),_getAssetTitle()and_getAssetParentId(); an item that inherits from its category needs none of this and uses the category asset (as incom_banners, which authorises against the category).
The SQL for the table, with the nullable checked_out columns, is part of the install script:
CREATE TABLE IF NOT EXISTS `#__tallpine_trails` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`asset_id` int unsigned NOT NULL DEFAULT 0,
`catid` int unsigned NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL,
`alias` varchar(255) NOT NULL DEFAULT '',
`distance_km` decimal(6,2) NOT NULL DEFAULT 0,
`elevation_gain` int unsigned NOT NULL DEFAULT 0,
`difficulty` varchar(16) NOT NULL DEFAULT 'easy',
`seasons` text,
`closed` tinyint NOT NULL DEFAULT 0,
`published` tinyint NOT NULL DEFAULT 0,
`access` int unsigned NOT NULL DEFAULT 1,
`language` char(7) NOT NULL DEFAULT '*',
`ordering` int NOT NULL DEFAULT 0,
`created` datetime NOT NULL,
`created_by` int unsigned NOT NULL DEFAULT 0,
`checked_out` int unsigned DEFAULT NULL,
`checked_out_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_catid` (`catid`),
KEY `idx_state` (`published`),
KEY `idx_checkout` (`checked_out`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;
MySQL/MariaDB and PostgreSQL need separate files; the query builder does not translate raw install SQL. See Installer Scripts and SQL Updates.
Forms: the XML behind the edit view
The edit form is an XML definition in forms/trail.xml. Field types, filters, validation rules and showon logic are
covered in Forms and Form Fields; the file for the trail looks like this.
<?xml version="1.0" encoding="utf-8"?>
<form>
<fieldset name="details" addfieldprefix="Joomla\Component\Categories\Administrator\Field">
<field name="id" type="number" label="JGLOBAL_FIELD_ID_LABEL" default="0" readonly="true" class="readonly" />
<field name="title" type="text" label="COM_TALLPINE_FIELD_TITLE_LABEL" required="true" maxlength="255" />
<field name="alias" type="text" label="JFIELD_ALIAS_LABEL" hint="JFIELD_ALIAS_PLACEHOLDER" />
<field name="catid" type="categoryedit" label="JCATEGORY" extension="com_tallpine" required="true" />
<field name="distance_km" type="number" label="COM_TALLPINE_FIELD_DISTANCE_LABEL" min="0" step="0.1" filter="float" />
<field name="elevation_gain" type="integer" label="COM_TALLPINE_FIELD_ELEVATION_LABEL" first="0" last="3000" step="50" />
<field name="difficulty" type="list" label="COM_TALLPINE_FIELD_DIFFICULTY_LABEL" default="easy" validate="options">
<option value="easy">COM_TALLPINE_DIFFICULTY_EASY</option>
<option value="moderate">COM_TALLPINE_DIFFICULTY_MODERATE</option>
<option value="hard">COM_TALLPINE_DIFFICULTY_HARD</option>
</field>
<field name="seasons" type="checkboxes" label="COM_TALLPINE_FIELD_SEASONS_LABEL">
<option value="spring">COM_TALLPINE_SEASON_SPRING</option>
<option value="summer">COM_TALLPINE_SEASON_SUMMER</option>
<option value="autumn">COM_TALLPINE_SEASON_AUTUMN</option>
<option value="winter">COM_TALLPINE_SEASON_WINTER</option>
</field>
<field name="published" type="list" label="JSTATUS" default="1" class="form-select-color-state" validate="options">
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
<option value="2">JARCHIVED</option>
<option value="-2">JTRASHED</option>
</field>
<field name="access" type="accesslevel" label="JFIELD_ACCESS_LABEL" />
<field name="language" type="contentlanguage" label="JFIELD_LANGUAGE_LABEL">
<option value="*">JALL</option>
</field>
<field name="checked_out" type="hidden" filter="unset" />
<field name="checked_out_time" type="hidden" filter="unset" />
</fieldset>
</form>
filter="unset" on the check-out columns is a security detail: the values must never come from the browser.
validate="options" rejects any list value that is not one of the declared options, so a forged difficulty fails
validation instead of reaching the database.
Views, layouts and the toolbar
A view class gathers data and prepares the toolbar; the layout in tmpl/ only prints. Both live under View/<Name>/HtmlView.php
and tmpl/<name>/<layout>.php, and the class name decides which layouts it can render. Template overrides of the
layouts are described in Template Overrides and Alternative Layouts.
The list view
The tutorial’s list view calls the model, then adds the toolbar. This version also exposes the filter form and the pagination to the layout.
<?php
namespace Tallpine\Component\Tallpine\Administrator\View\Trails;
\defined('_JEXEC') or die;
use Joomla\CMS\Helper\ContentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/** @var array */
protected $items = [];
/** @var \Joomla\CMS\Pagination\Pagination */
protected $pagination;
/** @var \Joomla\CMS\MVC\Model\State|\Joomla\Registry\Registry */
protected $state;
/** @var \Joomla\CMS\Form\Form */
public $filterForm;
/** @var array */
public $activeFilters = [];
public function display($tpl = null): void
{
$model = $this->getModel();
$this->items = $model->getItems();
$this->pagination = $model->getPagination();
$this->state = $model->getState();
$this->filterForm = $model->getFilterForm();
$this->activeFilters = $model->getActiveFilters();
if (\count($errors = $model->getErrors())) {
throw new GenericDataException(implode("\n", $errors), 500);
}
// The searchtools layout needs the "task" and "boxchecked" control fields in the filter form.
$this->filterForm->addControlField('task')->addControlField('boxchecked', '0');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
$canDo = ContentHelper::getActions('com_tallpine');
$user = $this->getCurrentUser();
$toolbar = $this->getDocument()->getToolbar();
ToolbarHelper::title(Text::_('COM_TALLPINE_TRAILS_TITLE'), 'map-marked-alt');
if ($canDo->get('core.create')) {
$toolbar->addNew('trail.add');
}
if ($canDo->get('core.edit.state')) {
$dropdown = $toolbar->dropdownButton('status-group', 'JTOOLBAR_CHANGE_STATUS')
->toggleSplit(false)
->icon('icon-ellipsis-h')
->buttonClass('btn btn-action')
->listCheck(true);
$childBar = $dropdown->getChildToolbar();
$childBar->publish('trails.publish')->listCheck(true);
$childBar->unpublish('trails.unpublish')->listCheck(true);
$childBar->standardButton('closed', 'COM_TALLPINE_TOOLBAR_CLOSE_SEASON', 'trails.closed')->listCheck(true);
$childBar->standardButton('reopened', 'COM_TALLPINE_TOOLBAR_REOPEN', 'trails.reopened')->listCheck(true);
if ($user->authorise('core.admin')) {
$childBar->checkin('trails.checkin');
}
if ($this->state->get('filter.published') != -2) {
$childBar->trash('trails.trash')->listCheck(true);
}
}
if ($canDo->get('core.delete') && $this->state->get('filter.published') == -2) {
$toolbar->delete('trails.delete', 'JTOOLBAR_DELETE_FROM_TRASH')
->message('JGLOBAL_CONFIRM_DELETE')
->listCheck(true);
}
if ($user->authorise('core.admin', 'com_tallpine') || $user->authorise('core.options', 'com_tallpine')) {
$toolbar->preferences('com_tallpine');
}
}
}
The toolbar is an object, not a list of echo statements. Since Joomla 4.0 the view obtains it from the document with
$this->getDocument()->getToolbar(), and each button is a fluent object: listCheck(true) makes it require a selected
row, message() adds a confirmation, icon() and buttonClass() style it. Buttons whose task is trails.publish post
the list form with that task, which the Dispatcher routes to TrailsController::publish(). The older static
ToolbarHelper::addNew(), publish(), deleteList() helpers still work; the tutorial mixes both styles and sets the
title with ToolbarHelper::title(), which has no $toolbar equivalent.
The core components in 6.2 have moved their list views to Joomla\CMS\MVC\View\ListView, a base class that fetches
items, pagination, state and filter form, adds the control fields and builds a complete toolbar from
configuration keys such as toolbar_icon, supports_batch and category (see com_banners in the 6.2 source, whose
constructor says @since 6.0.0). The Manual’s library-MVC page still advises not to use ListView and FormView
because core components did not; the 6.2 source shows that for lists they now do, so either style is acceptable for a
new component. The longhand style used here makes every step visible and does not depend on a class whose API is
younger.
The list layout
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Tallpine\Component\Tallpine\Administrator\View\Trails\HtmlView $this */
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('table.columns')->useScript('multiselect');
$user = $this->getCurrentUser();
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
?>
<form action="<?php echo Route::_('index.php?option=com_tallpine&view=trails'); ?>" method="post" name="adminForm" id="adminForm">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('INFO'); ?></span>
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
</div>
<?php else : ?>
<table class="table" id="trailList">
<caption class="visually-hidden">
<?php echo Text::_('COM_TALLPINE_TRAILS_TABLE_CAPTION'); ?>,
<span id="orderedBy"><?php echo Text::_('JGLOBAL_SORTED_BY'); ?> </span>,
<span id="filteredBy"><?php echo Text::_('JGLOBAL_FILTERED_BY'); ?></span>
</caption>
<thead>
<tr>
<td class="w-1 text-center"><?php echo HTMLHelper::_('grid.checkall'); ?></td>
<th scope="col" class="w-1 text-center"><?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?></th>
<th scope="col"><?php echo HTMLHelper::_('searchtools.sort', 'COM_TALLPINE_FIELD_TITLE_LABEL', 'a.title', $listDirn, $listOrder); ?></th>
<th scope="col" class="d-none d-md-table-cell"><?php echo HTMLHelper::_('searchtools.sort', 'COM_TALLPINE_FIELD_DIFFICULTY_LABEL', 'a.difficulty', $listDirn, $listOrder); ?></th>
<th scope="col" class="w-1 d-none d-md-table-cell"><?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) :
$canCheckin = $user->authorise('core.manage', 'com_checkin') || $item->checked_out == $user->id || is_null($item->checked_out);
$canEdit = $user->authorise('core.edit', 'com_tallpine.category.' . (int) $item->catid);
?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center"><?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?></td>
<td class="text-center"><?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'trails.', $canEdit, 'cb'); ?></td>
<th scope="row">
<?php if ($item->checked_out) : ?>
<?php echo HTMLHelper::_('jgrid.checkedout', $i, $item->editor, $item->checked_out_time, 'trails.', $canCheckin); ?>
<?php endif; ?>
<?php if ($canEdit) : ?>
<a href="<?php echo Route::_('index.php?option=com_tallpine&task=trail.edit&id=' . (int) $item->id); ?>">
<?php echo $this->escape($item->title); ?>
</a>
<?php else : ?>
<?php echo $this->escape($item->title); ?>
<?php endif; ?>
<div class="small"><?php echo $this->escape($item->category_title); ?></div>
</th>
<td class="d-none d-md-table-cell"><?php echo $this->escape($item->difficulty); ?></td>
<td class="d-none d-md-table-cell"><?php echo (int) $item->id; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</form>
Source: Joomla Manual: Component tutorial, Step 12; structure as in tmpl/banners/default.php of joomla-cms 6.2-dev
Points to remember in layouts:
-
Escape all output.
$this->escape()for text,(int)for numbers. A trail title is user input. -
The edit link is a task, not a view.
task=trail.edit&id=7is what checks the record out and holds the ID. Linking toview=trail&layout=edit&id=7directly is refused. -
The
adminFormform and the hiddentask/boxcheckedinputs are what the toolbar’s JavaScript submits. The nameadminFormis mandatory. -
Assets (
table.columns,multiselect) come from the Web Asset Manager; see Web Asset Manager. Inline<script>blocks are avoided. -
The layout PHP may not contain business logic. Anything that decides what is shown belongs in the view or the model.
The edit view
<?php
namespace Tallpine\Component\Tallpine\Administrator\View\Trail;
\defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\ContentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/** @var \Joomla\CMS\Form\Form */
protected $form;
/** @var \stdClass */
protected $item;
/** @var \Joomla\CMS\MVC\Model\State|\Joomla\Registry\Registry */
protected $state;
public function display($tpl = null): void
{
$model = $this->getModel();
$this->form = $model->getForm();
$this->item = $model->getItem();
$this->state = $model->getState();
if (\count($errors = $model->getErrors())) {
throw new GenericDataException(implode("\n", $errors), 500);
}
$this->addToolbar();
$this->form->addControlField('task');
parent::display($tpl);
}
protected function addToolbar(): void
{
// The administrator menu is hidden while a record is being edited.
Factory::getApplication()->getInput()->set('hidemainmenu', true);
$user = $this->getCurrentUser();
$isNew = ((int) $this->item->id === 0);
$checkedOut = !(\is_null($this->item->checked_out) || (int) $this->item->checked_out === (int) $user->id);
$toolbar = $this->getDocument()->getToolbar();
$canDo = ContentHelper::getActions('com_tallpine', 'category', $this->item->catid);
ToolbarHelper::title(Text::_($isNew ? 'COM_TALLPINE_TRAIL_NEW' : 'COM_TALLPINE_TRAIL_EDIT'), 'map-marked-alt');
$canSave = !$checkedOut && ($canDo->get('core.edit') || \count($user->getAuthorisedCategories('com_tallpine', 'core.create')) > 0);
if ($canSave) {
$toolbar->apply('trail.apply');
}
$toolbar->dropdownButton('save-group')->configure(
function (Toolbar $childBar) use ($canSave, $canDo, $isNew) {
if ($canSave) {
$childBar->save('trail.save');
if ($canDo->get('core.create')) {
$childBar->save2new('trail.save2new');
}
}
if (!$isNew && $canDo->get('core.create')) {
$childBar->save2copy('trail.save2copy');
}
}
);
$toolbar->cancel('trail.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
Source: joomla-cms 6.2-dev: com_banners edit view; Joomla Manual: Component tutorial, Step 13 Toolbars
Two details of the edit view are easy to miss. $this->item is the stdClass described earlier, so property access is
used, and checked_out is null for an unlocked row, so the comparison must use is_null() and not empty().
The toolbar offers the Save buttons only to a user who may save, and a record locked by another user shows only
the close button. The hidemainmenu input (read through the application input) greys out the administrator menu so
the editor cannot navigate away and leave the record locked.
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
/** @var \Tallpine\Component\Tallpine\Administrator\View\Trail\HtmlView $this */
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('keepalive')->useScript('form.validate');
?>
<form action="<?php echo Route::_('index.php?option=com_tallpine&layout=edit&id=' . (int) $this->item->id); ?>"
method="post" name="adminForm" id="trail-form" class="form-validate"
aria-label="<?php echo Text::_('COM_TALLPINE_TRAIL_' . ((int) $this->item->id === 0 ? 'NEW' : 'EDIT'), true); ?>">
<?php echo LayoutHelper::render('joomla.edit.title_alias', $this); ?>
<div class="main-card">
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderField('catid'); ?>
<?php echo $this->form->renderField('distance_km'); ?>
<?php echo $this->form->renderField('elevation_gain'); ?>
<?php echo $this->form->renderField('difficulty'); ?>
<?php echo $this->form->renderField('seasons'); ?>
</div>
<div class="col-lg-3">
<?php echo LayoutHelper::render('joomla.edit.global', $this); ?>
</div>
</div>
</div>
<?php echo $this->form->renderControlFields(); ?>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
keepalive pings the server so the session survives a long edit, and form.validate adds the client-side validation
driven by the form XML; neither replaces server validation. renderControlFields() prints the hidden task input
that the view registered, and the token field makes checkToken() pass on POST.
Filters and search tools
The search tools bar above the list is built from a second XML file named filter_<view>.xml in forms/. ListModel
loads it with getFilterForm(), fills it with the active state, and the joomla.searchtools.default layout renders it
and the "Clear" button. The filter fields control filters; the list fields control ordering and page size.
<?xml version="1.0" encoding="UTF-8"?>
<form addfieldprefix="Joomla\Component\Categories\Administrator\Field">
<fields name="filter">
<field name="search" type="text" inputmode="search" label="COM_TALLPINE_FILTER_SEARCH_LABEL"
description="COM_TALLPINE_FILTER_SEARCH_DESC" hint="JSEARCH_FILTER" />
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" class="js-select-submit-on-change"
extension="com_tallpine">
<option value="">JOPTION_SELECT_PUBLISHED</option>
</field>
<field name="category_id" type="category" label="JOPTION_FILTER_CATEGORY" extension="com_tallpine"
published="0,1,2" class="js-select-submit-on-change">
<option value="">JOPTION_SELECT_CATEGORY</option>
</field>
<field name="difficulty" type="list" label="COM_TALLPINE_FIELD_DIFFICULTY_LABEL" class="js-select-submit-on-change">
<option value="">COM_TALLPINE_SELECT_DIFFICULTY</option>
<option value="easy">COM_TALLPINE_DIFFICULTY_EASY</option>
<option value="moderate">COM_TALLPINE_DIFFICULTY_MODERATE</option>
<option value="hard">COM_TALLPINE_DIFFICULTY_HARD</option>
</field>
</fields>
<fields name="list">
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.title ASC"
class="js-select-submit-on-change" validate="options">
<option value="">JGLOBAL_SORT_BY</option>
<option value="a.title ASC">JGLOBAL_TITLE_ASC</option>
<option value="a.title DESC">JGLOBAL_TITLE_DESC</option>
<option value="a.difficulty ASC">COM_TALLPINE_DIFFICULTY_ASC</option>
<option value="a.difficulty DESC">COM_TALLPINE_DIFFICULTY_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
</field>
<field name="limit" type="limitbox" label="JGLOBAL_LIST_LIMIT" default="25" class="js-select-submit-on-change" />
</fields>
</form>
How the pieces connect:
-
The user changes a field with the class
js-select-submit-on-change, or presses Enter in the search box. The form is submitted by GET or POST to the same list view. -
ListModel::populateState()reads the arraysfilterandlistfrom the request, stores each key asfilter.<name>orlist.<name>in the state and remembers them in the user state of the session under the contextcom_tallpine.trails, so the filters survive a visit to the edit form and back. -
A
fullorderingvalue such asa.title DESCis split intolist.orderingandlist.directionif the column is infilter_fields. -
getListQuery()reads the state values and builds a bound query. -
The layout renders the active filters and the bar.
getActiveFilters()supplies the list of non-empty filters so the panel opens automatically when a filter is in effect.
Common mistakes:
-
A column missing from
filter_fields. Ordering by it silently reverts to the default. Add both the plain name and the aliased one (titleanda.title). -
A search box that hits
LIKEon unindexed columns of a large table. For the Tallpine list this is irrelevant, but a component with 100,000 rows should consider an index or Smart Search (see Smart Search). -
Reading
$_GETor$_REQUESTin the model. Use the state and the filtered$this->inputinstead. -
Forgetting the language keys for the filter labels. The bar shows raw keys, which is the first visible sign of a missing language file entry.
Post/Redirect/Get and check-in
The pattern
A Joomla component never answers a POST with the HTML of the next page. It processes the data, sets a message, sets a
redirect, and the Dispatcher’s $controller->redirect() performs a GET to the next view. This is the Post/Redirect/Get
pattern (the Manual’s page uses the spelling "Post-Request-Get"; the transcription "Post-Redirect-Get" is the usual name
for the same sequence). Its effects:
-
a browser refresh of the result page repeats the harmless GET and does not re-submit the form (no duplicate trail, no "resend form data?" prompt);
-
a message queued with
enqueueMessage()orsetMessage()is stored in the session and displayed once on the next request; -
the back button leads to a clean list, not to a POST result.
The Manual walks two examples through it: publishing from a list (GET list, POST articles.publish with cid[],
redirect, GET list with the message) and editing (GET article.edit, redirect to the form, GET the form, POST
article.save, redirect to the list). The com_tallpine equivalents are the sequence diagram at the top of this page.
A task method of your own follows the same recipe.
public function sticky(): void
{
// 1. Authenticate the request.
$this->checkToken();
// 2. Read and filter input.
$ids = array_filter((array) $this->input->get('cid', [], 'int'));
// 3. Act through the model; the model checks permissions per record.
if ($ids === []) {
$this->app->enqueueMessage(Text::_('COM_TALLPINE_NO_TRAILS_SELECTED'), 'warning');
} elseif (!$this->getModel()->stick($ids, 1)) {
$this->app->enqueueMessage($this->getModel()->getError(), 'warning');
} else {
$this->setMessage(Text::plural('COM_TALLPINE_N_TRAILS_STUCK', \count($ids)));
}
// 4. Redirect; never echo a page from a POST task.
$this->setRedirect(Route::_('index.php?option=com_tallpine&view=trails', false));
}
Redirect targets passed to setRedirect() should be built with Route::_(..., false) (no XML-escaping of &) or be
plain index.php?... strings; an absolute URL taken from the request is an open-redirect risk and is not used.
FormController::save() additionally stores the data of a failed validation in the user state and redirects back to
the form, so the Post/Redirect/Get applies even to errors.
Check-in and check-out
Check-out is Joomla’s pessimistic lock: while one user edits a record, others see a padlock and cannot open it. It needs
two columns on the table, checked_out (user ID, nullable) and checked_out_time. The components do the following
without any extra code, as soon as the columns exist:
-
FormController::edit()callsattemptCheckout(), which calls$model->checkout($id), which callsTable::checkOut(); a record already locked by someone else makeseditfail with a message and a redirect to the list. -
save()(includingapply,save2new) andcancel()callattemptCheckin(), so closing the form with the toolbar buttons releases the lock.applykeeps the form open and the record locked by re-checking it out. -
AdminController::checkin()is the Check-in button of the list for a super user: it takes the selected IDs and calls$model->checkin($ids), which clears the two columns on every locked row. -
The global System > Global Check-in screen of
com_checkinlists every table with achecked_outcolumn and releases all locks in one go. A table appears there automatically when it has both columns, so no registration is required.
Leaving the form by closing the browser tab, a session timeout or a PHP fatal error leaves the lock in place. That is
why the list shows a padlock icon with the name and time of the lock and why the check-in buttons exist; the keepalive
script on the edit layout reduces the timeouts but cannot prevent closed tabs. The code that is easiest to get wrong
is the nullable column: since the table columns are NULL when unlocked, a comparison such as
$item->checked_out == 0 (the Joomla 3 habit, older tutorials) is not a reliable test. Use is_null() or the table’s
own isCheckedOut() and checkIn().
// Releasing a lock programmatically, for example in an installer script or a CLI command.
$table = $this->getTable('Trail');
$table->load($id);
if ($table->checked_out) {
$table->checkIn($id);
}
Security checklist for the administrator side
-
Authorisation at every layer.
core.managein the Dispatcher,core.create/core.edit/core.edit.state/core.deletein the controllers and models (allowAdd(),allowEdit(),canEditState(),canDelete()), and the category asset for records that belong to a category. See ACL in Extensions and Users, Groups, Access Levels and ACL. -
CSRF. A form token in every form (
HTMLHelper::_('form.token')) and$this->checkToken()at the top of every task that writes, including custom ones. -
SQL injection. Bound parameters,
quoteName()for identifiers, a whitelist (filter_fields) for ordering. -
XSS.
$this->escape()for output, input filters on fields, and the text filter configuration for rich text. -
Mass assignment. Anything not listed in the form XML is removed by validation; the check-out columns are
unset. -
Direct file access. Each PHP file starts with
\defined('_JEXEC') or die;In Joomla 6 the constantJPATH_PLATFORMis no longer defined by default (it is available only through the 6.0 backward-compatibility plugin), so the olddefined('JPATH_PLATFORM')guards of extensions written for 3.x and 4.x must be replaced.
A fuller treatment, with the JED Checker rules, is in Security in Extensions.
Common errors and pitfalls
| Symptom | Likely cause |
|---|---|
"Class … not found" right after adding a controller or model |
The namespace in the manifest does not match the folder, a folder is missing from |
404 "Invalid controller class: trail" |
|
The edit page redirects to the list with "You are not permitted to use that link to directly access that page" |
The layout was opened without |
|
Code written for |
Filters do nothing, or ordering reverts to the default |
The column is not in |
The list shows every trail, whatever the filter |
|
The form loses its data after a validation error |
|
A record stays locked after an error |
The browser left the form without the toolbar’s Close button. Use Check-in in the list or System > Global Check-in. |
Duplicate rows after pressing refresh on the result page |
The task method outputs HTML instead of setting a redirect; apply the Post/Redirect/Get pattern. |
|
Use |
What changed, and what older books say
-
Joomla 4.0 introduced namespaced components with
services/provider.php, the MVCFactory and the Dispatcher, the toolbar object and the Web Asset Manager. Components written in the Joomla 3 style (JControllerLegacy,JTable,JFactory) run only through the compatibility layers that 6.0 has moved into the backward-compatibility plugin or removed. -
Joomla 4.3 deprecated
Table::getInstance()(removal in 7.0). -
Joomla 5.0 replaced the
CMSObject-based model state with the transitionalStateclass (deprecated at once, gone in 7.0). -
Joomla 5.4 added
setUseExceptions(true)to models; core removes itssetError()calls in 7.0. -
Joomla 6.0 makes
AdminModel::getItem()returnstdClass, removes$this->dbfrom views and shipsListView-based core list views. -
Joomla 7.0 removes the deprecated items above, and the automatic detection of
$appand$dbproperties in plugins.
Developing Extensions for Joomla! 5 (2023) is the best single book on this material and its namespaced MVC structure
is still correct; its examples that read Factory::getLanguage() or fetch the database driver from the container by
name, call Table::getInstance() or treat the result of getItem() as a CMSObject should be updated as above.
Joomla! 4 Masterclass (2023) describes the component screens from the user’s side and says little about code; what
it shows of the administrator list and edit screens predates 6.0, and its links into docs.joomla.org/J4.x now point
to a legacy wiki whose developer content has moved to the Developer Manual. Older tutorials also use JPATH_PLATFORM guards, the legacy
J* class aliases and non-namespaced controllers, none of which is a good starting point on 6.2; see
Where Older Books and Tutorials Are Outdated.
Testing and debugging the administrator code
A model is testable without a browser: build it through the MVCFactory with ignore_request => true, set the state
by hand and call getListQuery(); assertions on the generated SQL string or against a test database are the
practical choice. Controllers are tested with a stubbed application and input, and the whole path with Cypress, which
is what the core uses for its system tests. See Testing Extensions and
Debugging. For the web-services (API) half of the same entity, which reuses the models
and tables written here, see Extending the Web Services API.