Categories, Custom Fields and Workflow Integration

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 was written against Joomla 6.2.x (PHP 8.3 or later) and checked on 2026-10-30 against the Joomla! Developer Manual, the Joomla! User Guide and the 6.2.1 source tree of joomla-cms. It is written for the extension developer. It takes the component com_tallpine of the Tallpine Hiking Club (namespace Tallpine\Component\Tallpine, entity trail, table #__tallpine_trails) that is built in Component MVC in the administrator and gives it the five features that make a Joomla component feel like part of the core: nested categories, custom fields, tags, version history and workflows, plus multilingual associations for the German and English sites of the club.

The administrator and the site builder meet these features as screens, described in Categories and Tags, Custom Fields and Workflows. This page is the developer’s side of the same features: which interface the component implements, which trait provides the plumbing, which table interface, form field, configuration key, database row and behaviour plugin completes the picture, and what breaks when a piece is missing.

The shape of every integration

Joomla does not give a component these features through a base class. The core asks the component object for the capability it needs and checks the answer with instanceof. A component opts in by declaring an interface on its Extension class (the object services/provider.php returns for ComponentInterface, see Namespaces, autoloading and dependency injection). Each interface has a trait in the CMS that implements most of it, so the class is mostly a list of declarations. The interface alone is rarely enough: a table interface, a form field and a row in the content types table complete the integration.

Feature Extension class declares The rest of the component provides Core pieces that must be enabled

Categories

CategoryServiceInterface with CategoryServiceTrait

catid column, category form field, access.xml category section, manifest submenu entry, site Service\Category class

Categories component

Custom fields

FieldsFormServiceInterface with FieldsServiceTrait

Form named com_tallpine.trail with a catid and a language field, custom_fields_enable option

Fields component, System - Fields plugin, the Fields plugins for the types in use

Tags

TagServiceInterface with TagServiceTrait

Table with TaggableTableInterface, tags form field, a #__content_types row

Tags component, Behaviour - Taggable plugin

Versions

nothing on the Extension class

Table with VersionableTableInterface, model with VersionableModelInterface, controller trait, toolbar button, save_history option, a #__content_types row

Content History component, Behaviour - Versionable plugin

Workflows

WorkflowServiceInterface with WorkflowServiceTrait

Model with WorkflowBehaviorTrait, workflow_enabled option, a workflow and a stage for the context

Workflow component, the Workflow plugin group

Associations

AssociationServiceInterface with AssociationServiceTrait

AssociationExtensionInterface helper, model association context, associations form fields, site association helper

System - Language Filter plugin and Item Associations set to Yes

The calls come from many places, and it helps to see which core screen asks which question.

flowchart LR subgraph EXT["TallpineComponent (Extension class)"] CS[CategoryServiceInterface] FS[FieldsFormServiceInterface] TS[TagServiceInterface] WS[WorkflowServiceInterface] AS[AssociationServiceInterface] end CAT["Categories screens"] -- "countItems, prepareForm, getCategory" --> CS FLD["System - Fields plugin"] -- "validateSection, getContexts" --> FS TAG["Tags component"] -- "countTagItems" --> TS WFL["Workflow plugins and Workflows screens"] -- "isWorkflowActive, supportFunctionality, getWorkflowContexts" --> WS LNG["Language Filter and Associations component"] -- "getAssociationsExtension" --> AS

All of this is looked up through Factory::getApplication()->bootComponent('com_tallpine') (or its short name tallpine; bootComponent() removes a com_ prefix before it looks for the service provider). A component that does not implement an interface is simply invisible to that feature. That is why custom fields never appear in a form of a component that lacks FieldsServiceInterface, and why the category screen of such a component shows no item counts.

Wiring the component

services/provider.php

The provider registers the factories and builds the component object. Compared with the minimal provider of the MVC page, two registrations are added: the association helper and the category factory setter. The workflow, tags, fields and versioning features need no factory of their own.

<?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\Association\AssociationExtensionInterface;
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\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Tallpine\Component\Tallpine\Administrator\Extension\TallpineComponent;
use Tallpine\Component\Tallpine\Administrator\Helper\AssociationsHelper;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            AssociationExtensionInterface::class,
            static fn (Container $container) => new AssociationsHelper($container->get(DatabaseInterface::class))
        );

        $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->setAssociationExtension($container->get(AssociationExtensionInterface::class));
                $component->setRouterFactory($container->get(RouterFactoryInterface::class));

                return $component;
            }
        );
    }
};

Two failures are silent. If setCategoryFactory() is never called, getCategory() fails on a null factory the first time a site view asks for a category. If setAssociationExtension() is missing, getAssociationsExtension() returns null although it is declared to return an object, and the Language Filter plugin raises a type error on the site as soon as associations are on.

The Extension class

The class collects the interfaces. The three traits CategoryServiceTrait, TagServiceTrait and FieldsServiceTrait define methods with the same name (prepareForm, getTableNameForSection, getStateColumnForSection), so PHP needs an explicit conflict resolution with insteadof, exactly as the core components do. Background on traits is in PHP traits.

<?php

namespace Tallpine\Component\Tallpine\Administrator\Extension;

\defined('_JEXEC') or die;

use Joomla\CMS\Association\AssociationServiceInterface;
use Joomla\CMS\Association\AssociationServiceTrait;
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\Factory;
use Joomla\CMS\Fields\FieldsFormServiceInterface;
use Joomla\CMS\Fields\FieldsServiceTrait;
use Joomla\CMS\HTML\HTMLRegistryAwareTrait;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Tag\TagServiceInterface;
use Joomla\CMS\Tag\TagServiceTrait;
use Joomla\CMS\Workflow\WorkflowServiceInterface;
use Joomla\CMS\Workflow\WorkflowServiceTrait;
use Psr\Container\ContainerInterface;

class TallpineComponent extends MVCComponent implements
    BootableExtensionInterface,
    CategoryServiceInterface,
    FieldsFormServiceInterface,
    AssociationServiceInterface,
    WorkflowServiceInterface,
    RouterServiceInterface,
    TagServiceInterface
{
    use AssociationServiceTrait;
    use HTMLRegistryAwareTrait;
    use RouterServiceTrait;
    use WorkflowServiceTrait;
    use CategoryServiceTrait, TagServiceTrait, FieldsServiceTrait {
        CategoryServiceTrait::getTableNameForSection insteadof TagServiceTrait;
        CategoryServiceTrait::getStateColumnForSection insteadof TagServiceTrait;
        CategoryServiceTrait::prepareForm insteadof FieldsServiceTrait;
    }

    /**
     * Functionality the workflow plugins may take over for this component.
     * Only the published state is workflow-managed; there is no "featured" column.
     */
    protected $supportedFunctionality = [
        'core.state' => true,
    ];

    public function boot(ContainerInterface $container): void
    {
        // Nothing to register yet; HTML helpers would be added here.
    }

    /* ---- Fields ---- */

    public function validateSection($section, $item = null)
    {
        if (Factory::getApplication()->isClient('site')) {
            // The category list view and the front-end edit form use the trail context.
            if ($section === 'category' || $section === 'form') {
                $section = 'trail';
            }
        }

        return $section === 'trail' ? $section : null;
    }

    public function getContexts(): array
    {
        Factory::getApplication()->getLanguage()->load('com_tallpine', JPATH_ADMINISTRATOR);

        return [
            'com_tallpine.trail'      => Text::_('COM_TALLPINE_TRAILS'),
            'com_tallpine.categories' => Text::_('JCATEGORY'),
        ];
    }

    /* ---- Categories and tags: counting items per category and per tag ---- */

    protected function getTableNameForSection(?string $section = null)
    {
        // Without the table prefix: the counting helper adds it.
        return $section === 'category' ? 'categories' : 'tallpine_trails';
    }

    protected function getStateColumnForSection(?string $section = null)
    {
        return 'published';
    }

    /* ---- Workflows ---- */

    public function getWorkflowContexts(): array
    {
        Factory::getApplication()->getLanguage()->load('com_tallpine', JPATH_ADMINISTRATOR);

        return [
            'com_tallpine.trail' => Text::_('COM_TALLPINE_TRAILS'),
        ];
    }

    public function getCategoryWorkflowContext(?string $section = null): string
    {
        return array_key_first($this->getWorkflowContexts());
    }

    public function getWorkflowTableBySection(?string $section = null): string
    {
        return '#__tallpine_trails';
    }

    public function filterTransitions(array $transitions, int $pk): array
    {
        // Keep the transitions that start at the current stage or at "any stage" (-1).
        return array_values(
            array_filter(
                $transitions,
                static fn ($transition) => \in_array((int) ($transition['from_stage_id'] ?? 0), [-1, $pk], true)
            )
        );
    }
}

Points that surprise newcomers:

  • getTableNameForSection() returns the table name without the #__ prefix, because ContentHelper::countRelations() adds it. The workflow method getWorkflowTableBySection() is different: it returns the prefixed name, as com_content does with #__content.

  • validateSection() returns null for a section the component does not know, and that is how the Fields screens know that a context is not valid. It receives the section from the form name or the view name, so the site mapping of category and form to the entity section is part of the contract.

  • getContexts() and getWorkflowContexts() load the component language file themselves, because the Fields and Workflows screens can be rendered before the language of the component is loaded.

  • The final keyword is not used on the class. Core plugin classes are final, but the component Extension classes are not, and a test double may need to extend it.

  • In a component declared for both the site and the administrator, the same object serves both clients; use isClient() for the differences, as validateSection() does.

Categories

How categories work for a component

All categories of the site live in one table, #__categories, and the column extension partitions it. A category of com_tallpine has extension = 'com_tallpine'; a component with several kinds of categories uses dotted names such as com_tallpine.trail and com_tallpine.hut. The nested set columns lft, rgt and level make each partition a tree below the shared root, as described in the internals section of Categories and Tags. A record points to its category with a foreign key column, catid by convention, so #__tallpine_trails.catid holds the id of a category.

The Manual lists the steps a component takes; this page maps them to files.

Step Where

Column in the item table

catid int unsigned in the install SQL, with an index (see the SQL of the MVC page)

Form field

catid of type categoryedit (or category) with extension="com_tallpine" in forms/trail.xml

Administrator menu entry

A submenu item in the manifest linking to com_categories with extension=com_tallpine

Permissions

A category section in access.xml

Counting items

CategoryServiceInterface::countItems() through CategoryServiceTrait and getTableNameForSection()

Site access to the tree

Site\Service\Category class and the category factory in services/provider.php

Default category

An Uncategorised category created by the installer script

Deleting safely

A check that refuses to delete a category that still holds trails

Manifest, form and access rules

<!-- com_tallpine.xml (excerpt) -->
<administration>
    <menu img="class:map">COM_TALLPINE</menu>
    <submenu>
        <menu link="option=com_tallpine&amp;view=trails" img="class:map">COM_TALLPINE_TRAILS</menu>
        <menu link="option=com_categories&amp;view=categories&amp;extension=com_tallpine"
              view="categories" img="class:category">COM_TALLPINE_CATEGORIES</menu>
        <menu link="option=com_fields&amp;view=fields&amp;context=com_tallpine.trail"
              img="class:messages-add">COM_TALLPINE_FIELDS</menu>
        <menu link="option=com_fields&amp;view=groups&amp;context=com_tallpine.trail"
              img="class:messages-add">COM_TALLPINE_FIELD_GROUPS</menu>
    </submenu>
    <files folder="admin">
        <filename>access.xml</filename>
        <filename>config.xml</filename>
        <filename>tallpine.xml</filename>
        <folder>forms</folder>
        <folder>services</folder>
        <folder>sql</folder>
        <folder>src</folder>
        <folder>tmpl</folder>
    </files>
</administration>

The ampersands in the link attribute must be written & or the manifest is not valid XML and the installer rejects it. The two com_fields entries are what the core components get from their installation SQL; a third-party component can add the same links as submenu items. The administrator menu module hides them when the custom_fields_enable parameter of the component named in the link’s context is 0, which is why the options of the component need that field (shown under custom fields below).

The edit form carries the category field, the tags field, the version note and the language, all of which the integrations read by name.

<?xml version="1.0" encoding="UTF-8"?>
<!-- administrator/components/com_tallpine/forms/trail.xml (excerpt) -->
<form>
    <fieldset name="details" label="COM_TALLPINE_FIELDSET_DETAILS">
        <field name="id" type="number" label="JGLOBAL_FIELD_ID_LABEL" default="0" readonly="true" class="readonly" />

        <field name="title" type="text" label="JGLOBAL_TITLE" 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"
            addfieldprefix="Joomla\Component\Categories\Administrator\Field"
            required="true"
            default=""
        />

        <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" validate="options" />

        <field name="language" type="contentlanguage" label="JFIELD_LANGUAGE_LABEL">
            <option value="*">JALL</option>
        </field>

        <field name="tags" type="tag" label="JTAG" class="advancedSelect" multiple="true" />

        <field name="version_note" type="text" label="JGLOBAL_FIELD_VERSION_NOTE_LABEL" maxlength="255" />
    </fieldset>
</form>

The field for the category is named like the database column, so the automatic mapping between form and table works. The extension attribute decides which partition of the tree the drop-down shows. The custom-fields plugin reads the value of this field to decide which fields to show, and the Workflows code reads it to find the workflow of the category.

The component’s access.xml needs a category section next to the component section, as the Manual lists. The full file, with the fieldgroup and field sections that the custom fields need, is in Custom Fields; the permission model is in ACL in extensions.

<?xml version="1.0" encoding="UTF-8"?>
<access component="com_tallpine">
    <section name="component">
        <action name="core.admin" title="JACTION_ADMIN" />
        <action name="core.manage" title="JACTION_MANAGE" />
        <action name="core.create" title="JACTION_CREATE" />
        <action name="core.edit" title="JACTION_EDIT" />
        <action name="core.edit.state" title="JACTION_EDITSTATE" />
        <action name="core.delete" title="JACTION_DELETE" />
    </section>
    <section name="category">
        <action name="core.create" title="JACTION_CREATE" />
        <action name="core.delete" title="JACTION_DELETE" />
        <action name="core.edit" title="JACTION_EDIT" />
        <action name="core.edit.state" title="JACTION_EDITSTATE" />
    </section>
</access>

The site Category class and the Categories API

The category factory builds a class from the namespace and the section: for the extension com_tallpine without a section it expects Tallpine\Component\Tallpine\Site\Service\Category; for com_tallpine.trail it expects TrailCategory in the same namespace. If the class does not exist, the factory throws SectionNotFoundException.

<?php

namespace Tallpine\Component\Tallpine\Site\Service;

\defined('_JEXEC') or die;

use Joomla\CMS\Categories\Categories;

/**
 * Category tree of com_tallpine for the site.
 */
class Category extends Categories
{
    public function __construct($options = [])
    {
        $options['table']       = '#__tallpine_trails';
        $options['extension']   = 'com_tallpine';
        $options['accessfield'] = 'access';
        $options['statefield']  = 'published';
        $options['check_dates'] = false;

        parent::__construct($options);
    }
}

The class turns the options into the counting query: table is the item table, field defaults to catid, key to id, statefield to state (hence the override to published) and accessfield limits counts to items the current user may see. Read the tree through the component, not through a static call:

<?php

use Joomla\CMS\Factory;

// In a site model or view of com_tallpine.
$component  = Factory::getApplication()->bootComponent('com_tallpine');
$categories = $component->getCategory(['countItems' => 1, 'published' => 1]);

$root     = $categories->get('root');          // CategoryNode of the top of the partition
$alpine   = $categories->get($categoryId);     // one category, or null when not visible
$children = $alpine ? $alpine->getChildren() : [];

foreach ($children as $child) {
    // $child->title, $child->numitems, $child->getPath() ...
}

The older call Categories::getInstance('Tallpine') still works in 6.2 but has been deprecated since 4.0 with removal in 7.0; the 6.2 source names bootComponent($component)->getCategory($options, $section) as the replacement. The Manual page on the API shows the old call as the main entry and says the DI-based approach still has unresolved issues, so check the source of the version you target. The Manual spells the counting option countitems, while the 6.2 constructor reads the key countItems; use the spelling of the source.

The Uncategorised category and safe deletion

Core assumes that every item has a category, and multilingual associations depend on it. A fresh component therefore needs a default category, created by the installer script. The script is a service provider that registers an InstallerScriptInterface object; the trait supplies the five interface methods, so only postflight() is overridden. The surrounding mechanics are explained in Installer scripts and SQL updates.

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\Application\CMSApplicationInterface;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Installer\InstallerScriptInterface;
use Joomla\CMS\Installer\InstallerScriptTrait;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            InstallerScriptInterface::class,
            new class ($container->get(CMSApplicationInterface::class)) implements InstallerScriptInterface {
                use InstallerScriptTrait;

                protected string $extension = 'com_tallpine';

                public function __construct(CMSApplicationInterface $application)
                {
                    $this->setApplication($application);
                }

                public function postflight(string $type, InstallerAdapter $adapter): bool
                {
                    if ($type === 'install') {
                        $this->createDefaultCategory();
                    }

                    return parent::postflight($type, $adapter);
                }

                private function createDefaultCategory(): void
                {
                    $model = $this->getApplication()->bootComponent('com_categories')
                        ->getMVCFactory()
                        ->createModel('Category', 'Administrator', ['ignore_request' => true]);

                    $model->save([
                        'id'          => 0,
                        'parent_id'   => 1,
                        'extension'   => 'com_tallpine',
                        'title'       => 'Uncategorised',
                        'alias'       => 'uncategorised',
                        'published'   => 1,
                        'access'      => 1,
                        'language'    => '*',
                        'description' => '',
                    ]);
                }
            }
        );
    }
};

The Content - Joomla plugin refuses to delete a category that still contains items, but its list of tables is fixed to the core components (banners, contacts, articles, news feeds, user notes and web links). Deleting a Tallpine category that holds trails is therefore not blocked by core, and the trails keep a catid that points nowhere. The component guards itself with a small plugin that listens to the same event. The event class is BeforeDeleteEvent and a handler that adds false as a result vetoes the deletion.

<?php

namespace Tallpine\Plugin\Content\TallpineConditions\Extension;

\defined('_JEXEC') or die;

use Joomla\CMS\Event\Model\BeforeDeleteEvent;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\ParameterType;
use Joomla\Event\SubscriberInterface;

final class TallpineConditions extends CMSPlugin implements SubscriberInterface
{
    use DatabaseAwareTrait;

    public static function getSubscribedEvents(): array
    {
        return ['onContentBeforeDelete' => 'onContentBeforeDelete'];
    }

    public function onContentBeforeDelete(BeforeDeleteEvent $event): void
    {
        $category = $event->getItem();

        if ($event->getContext() !== 'com_categories.category' || $category->extension !== 'com_tallpine') {
            return;
        }

        $db      = $this->getDatabase();
        $id      = (int) $category->id;
        $query   = $db->createQuery()
            ->select('COUNT(*)')
            ->from($db->quoteName('#__tallpine_trails'))
            ->where($db->quoteName('catid') . ' = :catid')
            ->bind(':catid', $id, ParameterType::INTEGER);

        if ((int) $db->setQuery($query)->loadResult() > 0) {
            $this->getApplication()->enqueueMessage(Text::_('PLG_CONTENT_TALLPINECONDITIONS_CATEGORY_NOT_EMPTY'), 'error');
            $event->addResult(false);
        }
    }
}

The class receives its database through the aware trait, which the plugin’s services/provider.php fills with $plugin->setDatabase($container->get(DatabaseInterface::class)); see Plugin development and events. Nothing reads $this->db and nothing calls Factory::getDbo().

Categories with several kinds

A component that needs two category trees (trail categories and hut categories) uses dotted extension names. The pieces that change are the dotted extension in the form field and the submenu link, a TrailCategory and a HutCategory class in Site\Service, a separate column per tree, and a getTableNameForSection() and countItems() that map each section to its table and column. The validateSection() of the Fields service must also know both sections. Keep one tree unless two are really needed: every extra partition multiplies the permissions, menu entries and counting code.

Custom fields

The feature itself, its field types and the Tallpine trail fields are covered in Custom Fields. This section lists what the developer must supply and how the values travel.

What the component supplies

  • FieldsFormServiceInterface (or only FieldsServiceInterface when the component never changes its form), with validateSection() and getContexts() as in the Extension class above. FieldsFormServiceInterface adds prepareForm(), which the trait leaves empty; the category trait’s version wins in the conflict resolution because the category form needs it.

  • A form whose name is com_tallpine.trail, which is the model’s option and name. The System - Fields plugin builds the context from the form name.

  • A catid field, so that fields assigned to a category appear only for items of that category, and a language field, so that fields restricted to a language follow the item.

  • The option custom_fields_enable in config.xml, with default 1, which hides the Fields menu entries when the site builder switches fields off for the component.

<!-- administrator/components/com_tallpine/config.xml (excerpt) -->
<config>
    <fieldset name="integration" label="JGLOBAL_INTEGRATION_LABEL">
        <field
            name="custom_fields_enable"
            type="radio"
            label="JGLOBAL_CUSTOM_FIELDS_ENABLE_LABEL"
            layout="joomla.form.field.radio.switcher"
            default="1"
            >
            <option value="0">JNO</option>
            <option value="1">JYES</option>
        </field>
        <field
            name="save_history"
            type="radio"
            label="JGLOBAL_SAVE_HISTORY_OPTIONS_LABEL"
            layout="joomla.form.field.radio.switcher"
            default="0"
            >
            <option value="0">JNO</option>
            <option value="1">JYES</option>
        </field>
        <field
            name="history_limit"
            type="number"
            label="JGLOBAL_HISTORY_LIMIT_OPTIONS_LABEL"
            default="10"
            filter="integer"
            showon="save_history:1"
        />
        <field
            name="workflow_enabled"
            type="radio"
            label="JWORKFLOW_ENABLED_LABEL"
            layout="joomla.form.field.radio.switcher"
            default="0"
            >
            <option value="0">JNO</option>
            <option value="1">JYES</option>
        </field>
    </fieldset>
</config>

The same config.xml excerpt carries the three options used by the versioning and workflow sections below. The default of workflow_enabled is 0: workflows are opt-in for every component, as they are for articles.

How the values travel

The System - Fields plugin does the work through events that AdminModel::save() and the site views already trigger.

  1. onContentPrepareForm builds a com_fields group in the form, with one form field per custom field whose context, category and language match the item.

  2. onContentNormaliseRequestData gives unchecked checkboxes and cleared fields a defined value, so that clearing a field removes the stored value.

  3. After the table is stored, onContentAfterSave writes each value with FieldModel::setFieldValue() into the values table, keyed by field id and item id. An empty value deletes the row. A complex value (a subform or a multi value with named keys) is stored as JSON.

  4. onContentAfterDelete removes the values of the deleted item, so the trail’s custom field values go with it.

  5. On the site, onContentPrepare, onContentAfterTitle, onContentBeforeDisplay and onContentAfterDisplay fill $item->jcfields and print the fields the site builder assigned to those display positions.

Because the plugin needs the item to carry an id, the data saved from the form must come through the model in com_tallpine.trail context. A custom save() that bypasses AdminModel::save() skips the events and therefore the fields.

A site layout that prints the fields by hand, instead of Automatic Display, reads the prepared value. The prepared value has already passed through the field’s plugin and is escaped for HTML; the raw value is not.

<?php
// tmpl/trail/default.php (excerpt)

\defined('_JEXEC') or die;

use Joomla\CMS\Language\Text;
use Joomla\Component\Fields\Administrator\Helper\FieldsHelper;

$fields = FieldsHelper::getFields('com_tallpine.trail', $this->item, true);
?>
<dl class="trail-facts">
    <?php foreach ($fields as $field) : ?>
        <?php if ($field->value === '' || $field->value === null) : ?>
            <?php continue; ?>
        <?php endif; ?>
        <dt><?php echo $this->escape($field->label); ?></dt>
        <dd><?php echo $field->value; ?></dd>
    <?php endforeach; ?>
</dl>

Printing $field->rawvalue instead would bypass the field plugin’s escaping and is a stored cross-site scripting risk for any field editable by authors. The permission to change a value is a separate ACL action, Edit Value (core.edit.value), checked per field; the access.xml sections for fields and groups are in the Custom Fields page.

Tags

The pieces

Tagging stores the connection between an item and its tags in the table #__contentitem_tag_map and keeps a copy of the item’s core data in #__ucm_content so that the Tags component can list mixed content. Three things make a table taggable.

  1. The table class implements TaggableTableInterface and uses TaggableTableTrait, and sets its typeAlias.

  2. A row in #__content_types for that alias says which table, which form and which columns hold the title, state, alias and so on.

  3. The Behaviour - Taggable plugin is enabled. It listens to the table events (onTableObjectCreate, onTableBeforeStore, onTableAfterStore, onTableBeforeDelete, onTableSetNewTags, onTableAfterLoad) and calls the TagsHelper. The core tables hold no tag code of their own.

The form gets the tags field of type tag. AdminModel::save() copies $data['tags'] into $table->newTags, and the plugin does the rest. TagServiceInterface::countTagItems() lets the Tags screens show counts per tag; TagServiceTrait provides it, using the getTableNameForSection() and getStateColumnForSection() of the Extension class shown above.

The table gains the interface; the other changes of this page for versioning are in the same class.

<?php

namespace Tallpine\Component\Tallpine\Administrator\Table;

\defined('_JEXEC') or die;

use Joomla\CMS\Table\Table;
use Joomla\CMS\Tag\TaggableTableInterface;
use Joomla\CMS\Tag\TaggableTableTrait;
use Joomla\CMS\Versioning\VersionableTableInterface;
use Joomla\Database\DatabaseInterface;
use Joomla\Event\DispatcherInterface;

class TrailTable extends Table implements TaggableTableInterface, VersionableTableInterface
{
    use TaggableTableTrait;

    public function __construct(DatabaseInterface $db, ?DispatcherInterface $dispatcher = null)
    {
        // The alias ties the table to its row in #__content_types.
        $this->typeAlias = 'com_tallpine.trail';

        parent::__construct('#__tallpine_trails', 'id', $db, $dispatcher);
    }

    public function getTypeAlias(): string
    {
        return $this->typeAlias;
    }

    // check(), bind() and store() as in the MVC page.
}

The content type row

The row is data, not code, so it belongs in the install SQL (and in a SQL update for sites that already have the component). It serves tags and version history at once, and is removed on uninstall.

-- administrator/components/com_tallpine/sql/install.mysql.utf8.sql (excerpt)
INSERT INTO `#__content_types`
  (`type_title`, `type_alias`, `table`, `rules`, `field_mappings`, `router`, `content_history_options`)
SELECT
  'Tallpine Trail',
  'com_tallpine.trail',
  '{"special":{"dbtable":"#__tallpine_trails","key":"id","type":"TrailTable","prefix":"Tallpine\\\\Component\\\\Tallpine\\\\Administrator\\\\Table\\\\","config":"array()"},"common":{"dbtable":"#__ucm_content","key":"ucm_id","type":"Corecontent","prefix":"Joomla\\\\CMS\\\\Table\\\\","config":"array()"}}',
  '',
  '{"common":{"core_content_item_id":"id","core_title":"title","core_state":"published","core_alias":"alias","core_created_time":"created","core_created_user_id":"created_by","core_ordering":"ordering","core_access":"access","core_language":"language","core_catid":"catid"},"special":{}}',
  '',
  '{"formFile":"administrator\\/components\\/com_tallpine\\/forms\\/trail.xml","hideFields":["asset_id","checked_out","checked_out_time"],"ignoreChanges":["checked_out","checked_out_time","ordering"],"convertToInt":["ordering"],"displayLookup":[{"sourceColumn":"catid","targetTable":"#__categories","targetColumn":"id","displayColumn":"title"},{"sourceColumn":"created_by","targetTable":"#__users","targetColumn":"id","displayColumn":"name"},{"sourceColumn":"access","targetTable":"#__viewlevels","targetColumn":"id","displayColumn":"title"}]}'
WHERE NOT EXISTS (SELECT 1 FROM `#__content_types` WHERE `type_alias` = 'com_tallpine.trail');

-- administrator/components/com_tallpine/sql/uninstall.mysql.utf8.sql (excerpt)
DELETE FROM `#__content_types` WHERE `type_alias` = 'com_tallpine.trail';

The columns mean the following. table tells the UCM layer which table class loads the item. field_mappings maps the common UCM columns (core_title, core_state, core_catid, …​) onto the columns of the item table; the Tags component lists mixed content from #__ucm_content using them. router holds a legacy Class::method name from a helpers/route.php file; leave it empty and the Tags component builds a generic link index.php?option=com_tallpine&view=trail&id=ID, taking the view from the second part of the type alias, so keep the alias and the view name in step. content_history_options drives the Versions screen: formFile renders the diff, hideFields and ignoreChanges keep housekeeping columns out of the comparison, convertToInt normalises values before comparing, and displayLookup turns ids into readable titles. PostgreSQL needs its own file with the same row; the query builder does not translate raw install SQL.

Version history

Joomla calls the feature content history or versions. Every save stores a JSON snapshot of the item in #__history, the Versions toolbar button opens a modal to compare, restore or keep versions, and the component’s history_limit caps how many are kept per item.

The pieces

  1. The table implements VersionableTableInterface and returns its type alias (shown above).

  2. The model implements VersionableModelInterface and uses VersionableModelTrait, which supplies loadHistory(), saveHistory() and deleteHistory() and the logic that trims old versions to history_limit.

  3. The controller uses VersionableControllerTrait, which adds the loadhistory task that restores a version into the edit form.

  4. The edit view adds the toolbar button, only when the Content History component is enabled, the component option save_history is on, and the user may edit.

  5. The Behaviour - Versionable plugin listens to onTableAfterStore and onTableBeforeDelete. It does nothing unless the table is versionable and the component’s save_history is on, so versioning is off by default.

  6. The form has the version_note field, which the plugin reads from the submitted jform and stores as the version’s note.

  7. The #__content_types row, whose content_history_options render the comparison.

<?php
// administrator/components/com_tallpine/src/Controller/TrailController.php (excerpt)

namespace Tallpine\Component\Tallpine\Administrator\Controller;

\defined('_JEXEC') or die;

use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\Versioning\VersionableControllerTrait;

class TrailController extends FormController
{
    use VersionableControllerTrait;

    protected $view_list = 'trails';
}
<?php
// administrator/components/com_tallpine/src/View/Trail/HtmlView.php (excerpt of addToolbar)

use Joomla\CMS\Component\ComponentHelper;

if (!empty($this->item->id)) {
    $toolbar->cancel('trail.cancel');

    if (
        ComponentHelper::isEnabled('com_contenthistory')
        && $this->state->get('params')->get('save_history', 0)
        && $canDo->get('core.edit')
    ) {
        $toolbar->versions('com_tallpine.trail', $this->item->id);
    }
}

The model additions are shown together with the workflow code below, because both are traits of the same class. The user-facing side of versions is described in the Joomla User Guide: Article: Versions; it is the same for any versionable component.

When workflows are active for the component, Versioning::store() dispatches onContentVersioningPrepareTable so the workflow plugins can adjust the history record before it is stored.

Workflows

What a workflow integration is

A workflow replaces the free choice of the Status drop-down by stages and transitions. The component contributes the contract; the workflow component and plugins do the rest. The integration is the biggest of the page, and two things decide whether it is worth it for a small component: the editors must really need review steps, and every code path that creates or changes trails (web services, CLI, import scripts) must respect the workflow too. The decision guide is in Workflows; this section shows the code.

The state machine tables are shared by all components. #__workflows rows carry an extension such as com_tallpine.trail, #__workflow_stages and #__workflow_transitions hang off a workflow, and #__workflow_associations ties an item (item_id) to its current stage for an extension context. The published state of an item is derived from a transition’s options (the Workflow - Publishing plugin stores publishing there), which is why supportedFunctionality in the Extension class declares core.state.

The Extension class side

This is the WorkflowServiceInterface part of the class above. The trait supplies supportFunctionality() (reads $supportedFunctionality), isFunctionalityUsed() (asks the workflow plugins through onWorkflowFunctionalityUsed), getModelName(), isWorkflowActive() (the component option workflow_enabled is on and the model for the context implements WorkflowModelInterface) and getConditions(). The component has to supply:

Method What it returns for Tallpine

getWorkflowContexts()

com_tallpine.trail and its label; this fills the context drop-downs of the Workflows screens

getCategoryWorkflowContext()

The context whose workflow a category selects; the category edit form shows the Workflow tab only when it is active

getWorkflowTableBySection()

The prefixed table of the entity, used to join items with their stage

filterTransitions()

The transitions available from the current stage; -1 means "from any stage"

getModelName() (trait)

The model for a context; override it if the site and administrator models differ, as com_content does with its Form model

The model side

WorkflowBehaviorTrait carries the workflow behaviour of an item model. The model implements WorkflowModelInterface, calls setUpWorkflow() in its constructor and calls the trait’s hooks at the same points as ArticleModel. Order matters; the sequence below shows where each hook runs in a save.

sequenceDiagram participant E as Editor participant M as TrailModel participant T as TrailTable participant B as Behaviour plugins participant F as System - Fields plugin participant W as Workflow E->>M: save(data) M->>W: workflowBeforeSave (imports the workflow plugins) M->>T: bind and check T->>B: onTableBeforeStore (tags) M->>T: store T->>B: onTableAfterStore (tags and version history) M->>F: onContentAfterSave (custom field values) M->>M: associations saved when enabled M->>W: workflowAfterSave (new item gets its first stage, transition runs)
<?php

namespace Tallpine\Component\Tallpine\Administrator\Model;

\defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFactoryInterface;
use Joomla\CMS\Helper\TagsHelper;
use Joomla\CMS\Language\Associations;
use Joomla\CMS\Language\LanguageHelper;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\MVC\Model\WorkflowBehaviorTrait;
use Joomla\CMS\MVC\Model\WorkflowModelInterface;
use Joomla\CMS\Versioning\VersionableModelInterface;
use Joomla\CMS\Versioning\VersionableModelTrait;

class TrailModel extends AdminModel implements WorkflowModelInterface, VersionableModelInterface
{
    use WorkflowBehaviorTrait;
    use VersionableModelTrait;

    /** Context of the rows in #__associations. */
    protected $associationsContext = 'com_tallpine.item';

    public function __construct($config = [], ?MVCFactoryInterface $factory = null, ?FormFactoryInterface $formFactory = null)
    {
        parent::__construct($config, $factory, $formFactory);

        // Extension and section of the workflow; reads workflow_enabled from the component options.
        $this->setUpWorkflow('com_tallpine.trail');
    }

    public function getForm($data = [], $loadData = true)
    {
        $form = $this->loadForm('com_tallpine.trail', 'trail', ['control' => 'jform', 'load_data' => $loadData]);

        return $form ?: false;
    }

    public function getItem($pk = null)
    {
        $item = parent::getItem($pk);

        if ($item && !empty($item->id)) {
            // Tags are shown in the edit form from the helper.
            $item->tags = new TagsHelper();
            $item->tags->getTagIds($item->id, 'com_tallpine.trail');

            if (Associations::isEnabled()) {
                $item->associations = [];

                foreach (Associations::getAssociations('com_tallpine', '#__tallpine_trails', 'com_tallpine.item', $item->id) as $tag => $association) {
                    $item->associations[$tag] = $association->id;
                }
            }
        }

        return $item;
    }

    protected function preprocessForm(Form $form, $data, $group = 'content')
    {
        if (Associations::isEnabled()) {
            $languages = LanguageHelper::getContentLanguages(false, false, null, 'ordering', 'asc');

            if (\count($languages) > 1) {
                $addForm  = new \SimpleXMLElement('<form />');
                $fields   = $addForm->addChild('fields');
                $fields->addAttribute('name', 'associations');
                $fieldset = $fields->addChild('fieldset');
                $fieldset->addAttribute('name', 'item_associations');

                foreach ($languages as $language) {
                    $field = $fieldset->addChild('field');
                    $field->addAttribute('name', $language->lang_code);
                    $field->addAttribute('type', 'modal_trail');
                    $field->addAttribute('language', $language->lang_code);
                    $field->addAttribute('label', $language->title);
                    $field->addAttribute('translate_label', 'false');
                    $field->addAttribute('select', 'true');
                    $field->addAttribute('new', 'true');
                    $field->addAttribute('edit', 'true');
                    $field->addAttribute('clear', 'true');
                }

                $form->load($addForm, false);
            }
        }

        // Adds the "transition" field (hidden when workflows are off) and imports the workflow plugins.
        $this->workflowPreprocessForm($form, $data);

        parent::preprocessForm($form, $data, $group);
    }

    public function save($data)
    {
        $this->workflowBeforeSave();

        if (parent::save($data)) {
            // A new item always gets its first stage, even when workflows are off,
            // so that switching them on later finds every item in a stage.
            $this->workflowAfterSave($data);

            return true;
        }

        return false;
    }

    public function publish(&$pks, $value = 1)
    {
        $this->workflowBeforeStageChange();

        return parent::publish($pks, $value);
    }

    public function delete(&$pks)
    {
        $result = parent::delete($pks);

        if ($result) {
            $this->workflow->deleteAssociation($pks);
        }

        return $result;
    }

    protected function loadFormData()
    {
        $data = Factory::getApplication()->getUserState('com_tallpine.edit.trail.data', []);

        return $data ?: $this->getItem();
    }
}

Since 6.0 getItem() returns a stdClass and no longer a CMSObject, so assigning dynamic properties such as tags and associations is expected. The model gets its database from DatabaseAwareTrait (AdminModel already has it); implementing getDatabase() is mandatory for workflow models from 7.0, and the trait already triggers a deprecation notice if it is missing. Do not reach for the container in a model.

What the trait does, step by step:

  • setUpWorkflow('com_tallpine.trail') splits the string into the extension and the section, builds a Workflow object for it, reads workflow_enabled from the component parameters and, when workflows are on, adds the batch command Change workflow stage to the model.

  • workflowPreprocessForm() adds a transition field to the form: a grouped list of the transitions available from the item’s current stage when workflows are on, a hidden field otherwise. It also imports the plugin group workflow, so that the Workflow - Publishing plugin can turn the status drop-down into read-only text.

  • workflowAfterSave() creates the first association for a new item, using the default stage of the workflow selected by the item’s category (or the default workflow of the context), and executes the chosen transition.

  • workflowBeforeStageChange() imports the workflow plugins so a direct publish() of a row is intercepted by them.

  • Deleting an item must delete its association row, as delete() above does. Core articles do the same.

Workflow data, plugins and the category

Switching the option on is not enough; the context needs a workflow with a default stage. The administrator creates it in Components → Tallpine → Workflows (index.php?option=com_workflow&view=workflows&extension=com_tallpine.trail); the first workflow of a context becomes the default and gets a default stage automatically. A component that ships its own default can insert it in its install SQL; the table columns are those of the core tables, asset_id stays 0 and the site builder can add permissions later in the Workflows screens.

-- Optional: a default workflow and stage for com_tallpine.trail (MySQL).
INSERT INTO `#__workflows`
  (`published`, `title`, `description`, `extension`, `default`, `ordering`, `created`, `created_by`, `modified`, `modified_by`)
VALUES
  (1, 'COM_WORKFLOW_BASIC_WORKFLOW', '', 'com_tallpine.trail', 1, 1, CURRENT_TIMESTAMP(), 0, CURRENT_TIMESTAMP(), 0);

INSERT INTO `#__workflow_stages`
  (`ordering`, `workflow_id`, `published`, `title`, `description`, `default`)
SELECT 1, `id`, 1, 'COM_WORKFLOW_BASIC_STAGE', '', 1
FROM `#__workflows`
WHERE `extension` = 'com_tallpine.trail' AND `default` = 1;

The published state is changed by transitions, whose options the Workflow - Publishing plugin owns. Without transitions that publish or unpublish, a trail can never become visible once workflows are on. Core ships the Basic Workflow with seven transitions whose from_stage_id is -1; the Tallpine workflow of Workflows (Draft, In Review, Approved, Published) is built in the Workflows screens.

The category form shows a Workflow tab only if isWorkflowActive() is true for the context returned by getCategoryWorkflowContext(). The tab is a field named workflow_id in a params fieldset of the component’s own category form, as com_content has in forms/category.xml:

<!-- administrator/components/com_tallpine/forms/category.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<form>
    <fields name="params">
        <fieldset name="workflow" label="COM_TALLPINE_WORKFLOW">
            <field
                name="workflow_id"
                type="sql"
                label="COM_TALLPINE_WORKFLOWS"
                class="form-select"
                sql_select="id, title"
                sql_from="#__workflows"
                sql_where="published = 1 AND extension = 'com_tallpine.trail'"
                sql_order="ordering"
                key_field="id"
                value_field="title"
                required="true"
                default="use_default"
                translate="true"
            />
        </fieldset>
    </fields>
</form>

The workflow of an item is found by walking up the category path: a category with inherit takes the value of its parent, use_default stops the walk and takes the default workflow of the context, and a number selects that workflow (if it is published and has a published default stage).

Rules for workflow-aware code

  • Create items through the model. A trail inserted by SQL, an importer or a console command that writes the table directly has no row in #__workflow_associations, does not appear in list queries that join the stage, and cannot run transitions. The Tallpine tallpine:import console command must call the model, not the table.

  • When the option is on, never change published directly. Run a transition, which changes it through the plugin. The web services API has the same rule; see Extending the Web Services API.

  • The permission to run a transition is the ACL action core.execute.transition on the transition asset; the ability to edit the state is separate (see ACL in extensions).

  • Counts per category in a workflow-aware component can be given a uses_workflows and workflows_component configuration in countItems(), as com_content does, so that counts follow the condition of the stage and not only the published column.

Associations

Associations link the German and English versions of the same trail. They work only when the System - Language Filter plugin is enabled and Item Associations is set to Yes there. The multilingual configuration itself is described in Multilingual sites; the Manual page on multilingual content points to an article for third-party components that dates from Joomla 3, so the code below follows the 6.2 source.

The component implements AssociationServiceInterface, whose single method returns an object that implements AssociationExtensionInterface. AssociationServiceTrait stores the object injected by the provider. The model sets $associationsContext, which AdminModel::save() uses to write the #__associations rows: each row has the item’s id, the context and a key computed from the md5 of the associated ids. Items that share a key are translations of each other. The form needs one field per content language, built in preprocessForm() as shown above, with a modal field type for choosing the other-language trail.

<?php

namespace Tallpine\Component\Tallpine\Administrator\Helper;

\defined('_JEXEC') or die;

use Joomla\CMS\Association\AssociationExtensionHelper;
use Joomla\CMS\Language\Associations;
use Joomla\CMS\Table\Category;
use Joomla\Database\DatabaseInterface;
use Tallpine\Component\Tallpine\Administrator\Table\TrailTable;
use Tallpine\Component\Tallpine\Site\Helper\AssociationHelper;

class AssociationsHelper extends AssociationExtensionHelper
{
    protected $extension = 'com_tallpine';

    protected $itemTypes = ['trail', 'category'];

    protected $associationsSupport = true;

    public function __construct(private readonly DatabaseInterface $db)
    {
    }

    public function getAssociationsForItem($id = 0, $view = null)
    {
        return AssociationHelper::getAssociations($id, $view);
    }

    public function getAssociations($typeName, $id)
    {
        $type       = $this->getType($typeName);
        $context    = $typeName === 'category' ? 'com_categories.item' : $this->extension . '.item';
        $catidField = $typeName === 'category' ? '' : 'catid';

        return Associations::getAssociations($this->extension, $type['tables']['a'], $context, $id, 'id', 'alias', $catidField);
    }

    public function getType($typeName = '')
    {
        $fields  = $this->getFieldsTemplate();
        $support = $this->getSupportTemplate();
        $tables  = [];

        if ($typeName === 'trail') {
            $fields['title']      = 'a.title';
            $fields['state']      = 'a.published';
            $support['state']     = true;
            $support['acl']       = true;
            $support['checkout']  = true;
            $support['category']  = true;
            $support['save2copy'] = true;
            $tables               = ['a' => '#__tallpine_trails'];
        } elseif ($typeName === 'category') {
            $fields['created_user_id'] = 'a.created_user_id';
            $fields['ordering']        = 'a.lft';
            $fields['level']           = 'a.level';
            $fields['catid']           = '';
            $fields['state']           = 'a.published';
            $support['state']          = true;
            $support['acl']            = true;
            $support['checkout']       = true;
            $support['level']          = true;
            $tables                    = ['a' => '#__categories'];
        }

        return [
            'fields'  => $fields,
            'support' => $support,
            'tables'  => $tables,
            'joins'   => [],
            'title'   => $typeName,
        ];
    }

    public function getItem($typeName, $id)
    {
        if (empty($id)) {
            return null;
        }

        $table = $typeName === 'category' ? new Category($this->db) : new TrailTable($this->db);
        $table->load($id);

        return $table;
    }
}

getItem() returns the loaded row (a TrailTable or the category table), which the side-by-side editor of the Multilingual Associations component needs. The site half is a static AssociationHelper::getAssociations() that maps each associated trail to its SEF route in the other language, which the Language Switcher module uses to jump from a German trail to its English twin; it mirrors the com_contact helper and the routing page, Component site views and routing, explains the route builders it uses. Categories have their own association context, com_categories.item, so the category forms of the component also need the item_associations fieldset to be associable.

Security

  • Permissions are per asset. A trail takes its permissions from its category, so a Trail Warden group that may edit only the category Trails > Alpine is controlled in the category, not in the component. Core models that offer to create a category on the fly (the catid field with allowAdd) check the right to create categories first; a custom shortcut must do the same.

  • Printing custom field values. Use the prepared value, not rawvalue, and escape labels with $this->escape().

  • Version restore. The loadhistory task of VersionableControllerTrait calls allowEdit() for the item before it restores a version. A custom restore task has to repeat that check.

  • Workflow transitions. A transition is an ACL-controlled action. A web services route or console command that executes a transition by id must authorise the same action and not trust the id from the request.

  • Associations. The associated item is selected by id in a modal; the model casts the ids to integers before storing them. Do the same in any custom endpoint.

  • Category deletion. As shown above, the core plugin protects only core components. Without the guard of this page a deleted category leaves trails whose catid points nowhere, and those trails lose the permissions they inherited from the category.

  • Secrets. None of this code needs keys. Values such as the park-authority API token of the trail sync belong in the environment or the encrypted plugin parameters, never in the content types or workflow tables.

Common errors and pitfalls

Symptom Cause and fix

Fields do not appear in the Tallpine edit form

validateSection() returns null for the section, the form is not named com_tallpine.trail, the form has no catid field, or the option custom_fields_enable is 0. A field created for another context stays in that context.

Tags are not saved or the Tags screens show zero items

The table lacks TaggableTableInterface, the Behaviour - Taggable plugin is disabled, the #__content_types row is missing or its alias differs from the table’s typeAlias, or getTableNameForSection() returns the prefixed name.

The Versions button is missing or the list is empty

save_history is 0, the Behaviour - Versionable plugin or Content History component is disabled, the table does not implement VersionableTableInterface, or the #__content_types row is missing. Versions are not created retroactively.

Items are missing from lists after turning workflows on

Items created before the option was on, or outside the model, have no row in #__workflow_associations. The model creates the association for new items always; backfill the old ones with a one-off script.

The status drop-down is read-only, but nothing can be published

The context has no workflow with publishing transitions, or the user lacks core.execute.transition.

Class ... Site\Service\Category not found or SectionNotFoundException

The site Category class (or TrailCategory for a dotted section) is missing, or its namespace differs from the one passed to CategoryFactory in the provider.

Category counts are empty or wrong

getTableNameForSection() is not overridden (the trait returns null), it returns the prefixed name, or the state column is not published.

Association tab missing

Item Associations is off, there is only one content language, the form has no item_associations fieldset, or the Extension class does not implement AssociationServiceInterface.

A trait collision error when the class loads

The three traits define the same methods; add the insteadof rules for prepareForm, getTableNameForSection and getStateColumnForSection.

Deprecation notices about setError() or Factory::getDbo()

Core code still uses both in places, but they are not for new code: throw exceptions, take the database from DatabaseAwareTrait, and see Deprecations and preparing for Joomla 7.

Testing the integration

Check each feature once from the administrator after an install, in this order: create a category and a trail in it; add a custom field for the Trail context and see it in the edit form and the site view; tag the trail and open the tag listing; switch Save History on, save twice and compare the versions; switch Enable Workflows on, create the workflow, then publish through a transition; enable associations and link the English and German trails. Automated tests of the model and table logic, with a database, are in Testing extensions.

Version notes and corrections of older material

  • Categories have been partitioned by extension since Joomla 1.6. The interface-based integration (CategoryServiceInterface, the category factory and the Extension class) has been the model since 4.0. The static Categories::getInstance() was deprecated in 4.0 and is scheduled for removal in 7.0.

  • Custom fields came to Joomla in 3.7. FieldsServiceInterface is marked as available since 4.0.0 in the source.

  • Tags arrived in 3.1 and content history in 3.2. In 4.0 their table code moved from the tables into the Behaviour - Taggable and Behaviour - Versionable plugins.

  • Workflows arrived in 4.0. WorkflowServiceInterface and WorkflowBehaviorTrait have not changed incompatibly since; the visual workflow editor of 6.1 changes only the screens. The source and the User Guide are the references for workflows.

  • Events in plugins use concrete event classes since 5.0 (BeforeDeleteEvent, AfterSaveEvent and so on), and plugin classes get the application and database through getApplication() and the aware traits. 7.0 removes the automatic detection of the $app and $db plugin properties.

  • Joomla 6.0 made AdminModel::getItem() return stdClass, and moved the legacy J* class aliases and JPATH_PLATFORM behind the Behaviour - Backward Compatibility 6 plugin. Error reporting is moving from setError() to exceptions: setUseExceptions(true) exists since 5.4, core drops its setError() calls in 7.0 and the trait goes in 8.0. New code uses defined('_JEXEC') or die; and namespaced classes only.

  • Older books such as Developing Extensions for Joomla! 5 (2023) cover the categories and custom fields integration well, but show $db as a plugin property, Event::getArgument() arrays instead of event classes, Factory::getLanguage() and Factory::getDbo(); use the aware traits, the concrete event classes and getApplication()->getLanguage() as above. They do not cover the workflow integration in detail, associations helpers or the 6.x changes.

  • Joomla! 4 Masterclass (2023) explains workflows, custom fields and the Versions button from the user’s side for Joomla 4, which has been end of life since October 2025; the screens are the same in 6.2 but the Workflows visual editor and the 6.x defaults are newer. It does not cover the developer integration on this page.

  • Old tutorials that teach helpers/route.php classes, JTable::getInstance(), or a categories view in a models/forms folder describe Joomla 3. In 6.2 a legacy helpers/route.php is only consulted by the Tags component for the optional router value of a content type.

  • The Manual’s category pages target 6.1 as current. Their instructions are consistent with the 6.2.1 source used here, except for the capitalisation of the countItems option noted above.