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 |
|
|
Categories component |
Custom fields |
|
Form named |
Fields component, System - Fields plugin, the Fields plugins for the types in use |
Tags |
|
Table with |
Tags component, Behaviour - Taggable plugin |
Versions |
nothing on the Extension class |
Table with |
Content History component, Behaviour - Versionable plugin |
Workflows |
|
Model with |
Workflow component, the Workflow plugin group |
Associations |
|
|
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.
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;
}
);
}
};
Source: joomla-cms 6.2-dev: com_contact services/provider.php; Joomla Manual: Implementing Categories in your 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)
)
);
}
}
Source: joomla-cms 6.2-dev: ContentComponent, the reference implementation; joomla-cms 6.2-dev: WorkflowServiceInterface
Points that surprise newcomers:
-
getTableNameForSection()returns the table name without the#__prefix, becauseContentHelper::countRelations()adds it. The workflow methodgetWorkflowTableBySection()is different: it returns the prefixed name, ascom_contentdoes with#__content. -
validateSection()returnsnullfor 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 ofcategoryandformto the entity section is part of the contract. -
getContexts()andgetWorkflowContexts()load the component language file themselves, because the Fields and Workflows screens can be rendered before the language of the component is loaded. -
The
finalkeyword is not used on the class. Core plugin classes arefinal, 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, asvalidateSection()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 |
|
Form field |
|
Administrator menu entry |
A |
Permissions |
A |
Counting items |
|
Site access to the tree |
|
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&view=trails" img="class:map">COM_TALLPINE_TRAILS</menu>
<menu link="option=com_categories&view=categories&extension=com_tallpine"
view="categories" img="class:category">COM_TALLPINE_CATEGORIES</menu>
<menu link="option=com_fields&view=fields&context=com_tallpine.trail"
img="class:messages-add">COM_TALLPINE_FIELDS</menu>
<menu link="option=com_fields&view=groups&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' => '',
]);
}
}
);
}
};
Source: joomla-cms 6.2-dev: InstallerScriptTrait; Joomla Manual: Implementing Categories in your Component
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 onlyFieldsServiceInterfacewhen the component never changes its form), withvalidateSection()andgetContexts()as in the Extension class above.FieldsFormServiceInterfaceaddsprepareForm(), 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
catidfield, so that fields assigned to a category appear only for items of that category, and alanguagefield, so that fields restricted to a language follow the item. -
The option
custom_fields_enableinconfig.xml, with default1, 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.
-
onContentPrepareFormbuilds acom_fieldsgroup in the form, with one form field per custom field whose context, category and language match the item. -
onContentNormaliseRequestDatagives unchecked checkboxes and cleared fields a defined value, so that clearing a field removes the stored value. -
After the table is stored,
onContentAfterSavewrites each value withFieldModel::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. -
onContentAfterDeleteremoves the values of the deleted item, so the trail’s custom field values go with it. -
On the site,
onContentPrepare,onContentAfterTitle,onContentBeforeDisplayandonContentAfterDisplayfill$item->jcfieldsand 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.
-
The table class implements
TaggableTableInterfaceand usesTaggableTableTrait, and sets itstypeAlias. -
A row in
#__content_typesfor that alias says which table, which form and which columns hold the title, state, alias and so on. -
The Behaviour - Taggable plugin is enabled. It listens to the table events (
onTableObjectCreate,onTableBeforeStore,onTableAfterStore,onTableBeforeDelete,onTableSetNewTags,onTableAfterLoad) and calls theTagsHelper. 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
-
The table implements
VersionableTableInterfaceand returns its type alias (shown above). -
The model implements
VersionableModelInterfaceand usesVersionableModelTrait, which suppliesloadHistory(),saveHistory()anddeleteHistory()and the logic that trims old versions tohistory_limit. -
The controller uses
VersionableControllerTrait, which adds theloadhistorytask that restores a version into the edit form. -
The edit view adds the toolbar button, only when the Content History component is enabled, the component option
save_historyis on, and the user may edit. -
The Behaviour - Versionable plugin listens to
onTableAfterStoreandonTableBeforeDelete. It does nothing unless the table is versionable and the component’ssave_historyis on, so versioning is off by default. -
The form has the
version_notefield, which the plugin reads from the submittedjformand stores as the version’s note. -
The
#__content_typesrow, whosecontent_history_optionsrender 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);
}
}
Source: joomla-cms 6.2-dev: com_banners Banner HtmlView; joomla-cms 6.2-dev: Behaviour - Versionable plugin
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 |
|---|---|
|
|
|
The context whose workflow a category selects; the category edit form shows the Workflow tab only when it is active |
|
The prefixed table of the entity, used to join items with their stage |
|
The transitions available from the current stage; |
|
The model for a context; override it if the site and administrator models differ, as |
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.
<?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 aWorkflowobject for it, readsworkflow_enabledfrom the component parameters and, when workflows are on, adds the batch command Change workflow stage to the model. -
workflowPreprocessForm()adds atransitionfield 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 groupworkflow, 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 directpublish()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 Tallpinetallpine:importconsole command must call the model, not the table. -
When the option is on, never change
publisheddirectly. 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.transitionon 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_workflowsandworkflows_componentconfiguration incountItems(), ascom_contentdoes, so that counts follow the condition of the stage and not only thepublishedcolumn.
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
catidfield withallowAdd) check the right to create categories first; a custom shortcut must do the same. -
Printing custom field values. Use the prepared
value, notrawvalue, and escape labels with$this->escape(). -
Version restore. The
loadhistorytask ofVersionableControllerTraitcallsallowEdit()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
catidpoints 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 |
|
Tags are not saved or the Tags screens show zero items |
The table lacks |
The Versions button is missing or the list is empty |
|
Items are missing from lists after turning workflows on |
Items created before the option was on, or outside the model, have no row in |
The status drop-down is read-only, but nothing can be published |
The context has no workflow with publishing transitions, or the user lacks |
|
The site |
Category counts are empty or wrong |
|
Association tab missing |
Item Associations is off, there is only one content language, the form has no |
A trait collision error when the class loads |
The three traits define the same methods; add the |
Deprecation notices about |
Core code still uses both in places, but they are not for new code: throw exceptions, take the database from
|
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
extensionsince Joomla 1.6. The interface-based integration (CategoryServiceInterface, the category factory and the Extension class) has been the model since 4.0. The staticCategories::getInstance()was deprecated in 4.0 and is scheduled for removal in 7.0. -
Custom fields came to Joomla in 3.7.
FieldsServiceInterfaceis 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.
WorkflowServiceInterfaceandWorkflowBehaviorTraithave 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,AfterSaveEventand so on), and plugin classes get the application and database throughgetApplication()and the aware traits. 7.0 removes the automatic detection of the$appand$dbplugin properties. -
Joomla 6.0 made
AdminModel::getItem()returnstdClass, and moved the legacyJ*class aliases andJPATH_PLATFORMbehind the Behaviour - Backward Compatibility 6 plugin. Error reporting is moving fromsetError()to exceptions:setUseExceptions(true)exists since 5.4, core drops itssetError()calls in 7.0 and the trait goes in 8.0. New code usesdefined('_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
$dbas a plugin property,Event::getArgument()arrays instead of event classes,Factory::getLanguage()andFactory::getDbo(); use the aware traits, the concrete event classes andgetApplication()->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.phpclasses,JTable::getInstance(), or acategoriesview in amodels/formsfolder describe Joomla 3. In 6.2 a legacyhelpers/route.phpis only consulted by the Tags component for the optionalroutervalue 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
countItemsoption noted above.