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

administrator/components/com_tallpine/services/provider.php

Registers the MVCFactory, the dispatcher factory, the router factory and the component object in the container.

Extension class

Tallpine\Component\Tallpine\Administrator\Extension\TallpineComponent

The object the container returns for ComponentInterface. Extends MVCComponent; may implement boot, category, tag and router interfaces.

Dispatcher

Tallpine\Component\Tallpine\Administrator\Dispatcher\Dispatcher

Optional. Extends ComponentDispatcher to customise the access check or language loading.

Controller

DisplayController, TrailController (FormController), TrailsController (AdminController)

Reads the request, checks the token and permissions, calls the model, sets a message and a redirect.

Model

TrailsModel (ListModel), TrailModel (AdminModel)

Builds the list query and filters, loads, validates and stores one record, handles check-in and check-out.

Table

TrailTable (Table)

Row object for #__tallpine_trails: binds, checks, stores, checks in and out.

View

View\Trails\HtmlView, View\Trail\HtmlView

Collects data from the model, builds the toolbar, renders tmpl/trails/default.php and tmpl/trail/edit.php.

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.

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.

sequenceDiagram autonumber actor Editor as Editor (browser) participant D as Dispatcher participant C as TrailController (FormController) participant M as TrailModel (AdminModel) participant T as TrailTable participant V as Trail HtmlView Editor->>D: GET task=trail.edit&id=7 D->>C: createController + execute("edit") C->>M: checkout(7) M->>T: load(7), checkOut(userId) C-->>Editor: 303 redirect to view=trail&layout=edit&id=7 Editor->>D: GET view=trail&layout=edit&id=7 D->>V: DisplayController::display() V->>M: getForm(), getItem() M->>T: load(7) V-->>Editor: HTML edit form Editor->>D: POST task=trail.save (jform[...] + token) D->>C: execute("save") C->>M: validate(form, data) C->>M: save(validData) M->>T: bind, check, store C->>M: checkin(7) C->>D: setMessage + setRedirect(view=trails) D-->>Editor: redirect, message kept in the session Editor->>D: GET view=trails D->>V: DisplayController::display() V-->>Editor: HTML list with the message

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&amp;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:

  1. calls checkAccess(), which in the administrator client throws a NotAllowed exception (HTTP 403) unless the user has core.manage on the component;

  2. reads the task parameter (default display) and, when it contains a dot, splits it into controller and task: trail.save runs execute('save') on TrailController;

  3. creates the controller through the MVCFactory, passing the client (Administrator or Site) and the request input;

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

add

Checks core.create (allowAdd()), clears the held edit ID and redirects to the blank form (layout=edit).

edit

Checks core.edit (allowEdit()), checks the record out for the current user when the table has checked_out and checked_out_time, remembers the ID in the user state (holdEditId()) and redirects to the form.

save, apply, save2new, save2copy

One method: apply and the others are registered as aliases and tell save() where to redirect with getTask(). Validates the token and the data, stores, checks in and redirects.

cancel

Checks the token, checks the record in, releases the held edit ID and redirects to the list.

batch

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 helper array_filter drops the zeros that filtering produces from garbage.

  • The list controller uses the singular model. The plural TrailsModel only reads; writing is done by TrailModel (an AdminModel). That separation is also what lets the ListModel stay 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

BaseController (DisplayController)

ListModel

Operations on selected rows

AdminController

AdminModel

Show an edit form

BaseController

AdminModel (or FormModel if the model only validates and never stores a table row)

Handle the POST of the edit form

FormController

AdminModel

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 and whereIn() for lists. Concatenating a request value into the string is the classic SQL-injection mistake of old tutorials. Identifiers go through quoteName().

  • Pagination is not written by the query. ListModel::getItems() adds LIMIT/OFFSET from list.start and list.limit, and getPagination() and getTotal() use the same query. A count query is derived from it, so avoid GROUP BY constructions that make the derived count wrong.

  • getStoreId() is the cache key of getItems(). 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 that populateState() 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;
    }
}

The pieces AdminModel brings that the page relies on:

  • getForm() and loadForm() find forms/trail.xml in the component’s forms folder; the control name jform produces fields named jform[title]. The plugin event onContentPrepareForm lets other extensions (custom fields, for instance) add fields to the form, and preprocessForm() is the hook for a component’s own additions.

  • loadFormData() returns the user-state data first. FormController::save() stores the invalid input under com_tallpine.edit.trail.data before 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()), fires onContentBeforeSave and onContentAfterSave and clears the component cache. delete(), publish(), reorder() and batch*() are similar.

  • validate($form, $data) (from FormModel) filters and validates the submitted array against the form XML and returns the cleaned data, or false. The filtering rules of the form fields apply, which is why a field with filter="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 CMSObject such 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->params is an array, not a Registry; 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 null from get();

  • code that extended the model and returned a CMSObject from its own getItem() 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 call createTable(). The core tables write new self($db, $this->getDispatcher()) (see BannerTable::store()) when they need a second row object, and new ClientTable($db) for a different table class.

  • Validate in check(), not in the controller. check() runs on every store() 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 returns false and calls setError() (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_out and checked_out_time takes part in check-out, and Table::checkOut() and Table::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 in com_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=7 is what checks the record out and holds the ID. Linking to view=trail&layout=edit&id=7 directly is refused.

  • The adminForm form and the hidden task/boxchecked inputs are what the toolbar’s JavaScript submits. The name adminForm is 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');
    }
}

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:

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

  2. ListModel::populateState() reads the arrays filter and list from the request, stores each key as filter.<name> or list.<name> in the state and remembers them in the user state of the session under the context com_tallpine.trails, so the filters survive a visit to the edit form and back.

  3. A fullordering value such as a.title DESC is split into list.ordering and list.direction if the column is in filter_fields.

  4. getListQuery() reads the state values and builds a bound query.

  5. 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 (title and a.title).

  • A search box that hits LIKE on 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 $_GET or $_REQUEST in the model. Use the state and the filtered $this->input instead.

  • 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() or setMessage() 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:

  1. FormController::edit() calls attemptCheckout(), which calls $model->checkout($id), which calls Table::checkOut(); a record already locked by someone else makes edit fail with a message and a redirect to the list.

  2. save() (including apply, save2new) and cancel() call attemptCheckin(), so closing the form with the toolbar buttons releases the lock. apply keeps the form open and the record locked by re-checking it out.

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

  4. The global System > Global Check-in screen of com_checkin lists every table with a checked_out column 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.manage in the Dispatcher, core.create/core.edit/core.edit.state/ core.delete in 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 constant JPATH_PLATFORM is no longer defined by default (it is available only through the 6.0 backward-compatibility plugin), so the old defined('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 <files>, or the file name differs in case (TrailsModel.php versus trailsmodel.php; Linux is case-sensitive).

404 "Invalid controller class: trail"

TrailController is not in Controller/ of the right client, or the Dispatcher is called for Site while the class is in Administrator.

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 task=trail.edit; the edit ID is not held. Link to the task or use trail.add.

Call to undefined method stdClass::get()

Code written for CMSObject is running against Joomla 6.0+, where AdminModel::getItem() returns stdClass.

Filters do nothing, or ordering reverts to the default

The column is not in filter_fields, or the filter XML file has a different name than filter_<view>.xml.

The list shows every trail, whatever the filter

getStoreId() or the getListQuery() ignores a state key, or the filter field name differs from the state name.

The form loses its data after a validation error

loadFormData() does not read getUserState('com_tallpine.edit.trail.data'), or ignore_request hides it.

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.

Table::getInstance() produces deprecation warnings

Use $this->getTable() in models or new TrailTable($db, $dispatcher).

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 transitional State class (deprecated at once, gone in 7.0).

  • Joomla 5.4 added setUseExceptions(true) to models; core removes its setError() calls in 7.0.

  • Joomla 6.0 makes AdminModel::getItem() return stdClass, removes $this->db from views and ships ListView-based core list views.

  • Joomla 7.0 removes the deprecated items above, and the automatic detection of $app and $db properties 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.