Joomla Forms and Form Fields

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.

Almost everything an editor types into Joomla goes through a form: the article editor, the module and plugin parameters, the global configuration, the registration page, the Smart Search filters and every custom component screen. Joomla describes a form in an XML file, turns the XML into a Form object that holds a list of field objects, and uses that one description three times: to render the HTML, to filter and validate what the browser posts back, and to let plugins add or remove fields without touching the component. This page was checked on 2026-10-30 against Joomla 6.2.x (the Developer Manual for 6.1 and the 6.2-dev source tree) and PHP 8.3 or later.

The reader is mostly a developer of com_tallpine or of a plugin that extends someone else’s form, but a site builder meets the same vocabulary in the field options of the custom-field screens (see Joomla custom fields), and a themer meets it when overriding the layouts under html/layouts/joomla/form/field (see Template overrides and alternative layouts). The running example is the Tallpine Hiking Club trail report: the form behind the administrator edit screen of the trail entity of com_tallpine, which has a title, a category, a distance, a difficulty grade, a season, a repeatable list of waypoints and an optional closure period.

Three facts frame everything below.

  • Joomla ships 80 standard field types (the Developer Manual lists exactly 80 in its 6.1 documentation), plus the field types supplied by fields plugins for custom fields. Date and Datetime are new in 6.0 (the classes carry @since 6.0.0), next to the older Calendar.

  • Filtering and validation are two separate, server-side steps, and client-side validation is only a convenience on top of them. The browser can always be bypassed with curl.

  • A form is not one page’s business. Plugins that listen to onContentPrepareForm can change any form that passes through the model layer, so a form definition is part of a component’s public extension surface.

Anatomy of a form XML file

A form file has a <form> root, optional <fieldset> containers and <field> elements. By convention a component keeps its form definitions in administrator/components/com_tallpine/forms/, and site-side forms in components/com_tallpine/forms/. Here is the form for the Tallpine trail:

<?xml version="1.0" encoding="UTF-8"?>
<form addfieldprefix="Tallpine\Component\Tallpine\Administrator\Field"
      addruleprefix="Tallpine\Component\Tallpine\Administrator\Rule"
      addfilterprefix="Tallpine\Component\Tallpine\Administrator\Filter">
    <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"
            hint="COM_TALLPINE_FIELD_TITLE_HINT"
        />
        <field
            name="alias"
            type="text"
            label="JFIELD_ALIAS_LABEL"
            hint="JFIELD_ALIAS_PLACEHOLDER"
        />
        <field
            name="catid"
            type="category"
            label="JCATEGORY"
            extension="com_tallpine"
            required="true"
        />
        <field
            name="distance_km"
            type="number"
            label="COM_TALLPINE_FIELD_DISTANCE_LABEL"
            description="COM_TALLPINE_FIELD_DISTANCE_DESC"
            min="0.5"
            max="120"
            step="0.5"
            validate="number"
            required="true"
        />
        <field
            name="difficulty"
            type="grade"
            label="COM_TALLPINE_FIELD_DIFFICULTY_LABEL"
            default="easy"
        />
        <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="closed"
            type="radio"
            label="COM_TALLPINE_FIELD_CLOSED_LABEL"
            layout="joomla.form.field.radio.switcher"
            default="0"
            filter="integer"
        >
            <option value="0">JNO</option>
            <option value="1">JYES</option>
        </field>
        <field
            name="closed_from"
            type="date"
            label="COM_TALLPINE_FIELD_CLOSED_FROM_LABEL"
            showon="closed:1"
        />
        <field
            name="closure_reason"
            type="textarea"
            label="COM_TALLPINE_FIELD_CLOSURE_REASON_LABEL"
            rows="3"
            filter="safehtml"
            showon="closed:1"
        />
    </fieldset>

    <fields name="params">
        <fieldset name="display" label="COM_TALLPINE_FIELDSET_DISPLAY">
            <field
                name="show_map"
                type="radio"
                label="COM_TALLPINE_FIELD_SHOW_MAP_LABEL"
                layout="joomla.form.field.radio.switcher"
                default="1"
            >
                <option value="0">JHIDE</option>
                <option value="1">JSHOW</option>
            </field>
        </fieldset>
    </fields>
</form>

The structure rewards a close reading.

<form> is the root. The prefix attributes addfieldprefix, addruleprefix, addfilterprefix (and addformprefix) may sit on the root, on a <fieldset>, on a <fields> group or on a single <field>. They register a PHP namespace prefix with FormHelper, which then looks for <Prefix>\<Name>Field, <Prefix>\<Name>Rule or <Prefix>\<Name>Filter when it meets a type, a validate name or a filter name it does not know. The prefix list starts with the core namespaces (Joomla\CMS\Form\Field, Joomla\CMS\Form\Rule, Joomla\CMS\Form\Filter) and each added prefix is put in front of them, so a component can shadow a core type by reusing its name. Prefixes are global to the request: once added they remain for every later form, which is convenient and a source of surprises in long-lived processes.

<fieldset> groups fields for display. It carries a name and a label (a language key) and, optionally, description. It does not change the structure of the posted data. Tabs in the standard administrator layouts are built by iterating getFieldsets() (see LayoutHelper and joomla.edit.params in Component MVC in the administrator).

<fields name="params"> is a field group. It does change the data shape: every field inside is named jform[params][show_map] in the HTML and nested as params.show_map in the bound array. This is how Joomla stores a JSON params column in a single database field. Groups nest, and Joomla joins the nested names with dots when it filters and validates.

<field> has two mandatory attributes, name and type. The type is resolved case-insensitively to a class, so type="grade" finds GradeField in the registered prefixes. A field without type becomes a text field.

The <option> children of list, radio, checkboxes and similar types give the choices. Language keys in the label, description, hint and option text are translated for you (see Language files and internationalisation); use translate_label="false" or translate_description="false" to switch it off.

Common field attributes

Many attributes map straight to HTML (size, maxlength, rows, cols, readonly, disabled, autofocus, onchange, data-*). The Manual explains the less obvious ones, and the base class FormField::__set documents the general set. The table summarises the attributes a component author uses constantly.

Attribute Meaning

name

The key in the posted and bound data. It must be unique within its field group. In the HTML it becomes <control>[<group>][<name>].

type

The field type (text, list, subform, grade, …​). Resolved through the registered field prefixes.

label, description

Language keys. Since Joomla 4 the description is shown as inline help below the field; before that it was a tooltip. The administrator toolbar button Toggle Inline Help shows or hides it. A component enables the button with $toolbar->inlinehelp() and, in the form XML, <config><inlinehelp button="show"/></config>.

default

The initial value. Data bound to the form always overrides it, so a re-displayed invalid form keeps what the user typed (see "Loading and binding").

required

The browser blocks an empty submission, and the server-side validate() returns an "is required" error for an empty value.

readonly, disabled

readonly is displayed but still posted; disabled is displayed but not posted. Because a disabled input is never sent, the server treats a disabled field that nevertheless arrives in the request as an attack and throws an exception.

hint

The HTML placeholder.

class

The CSS class of the input. Joomla also reads validate-* classes from it for client-side validation.

pattern

A regular expression used by the browser for text and tel-like fields. It has no effect on the server.

filter

How the posted value is cleaned (see "Filters").

validate

The server-side rule name (see "Validation rules").

showon

Show the field only when another field has a given value (see "Conditional display with showon").

layout

Overrides the layout that renders the field, for example joomla.form.field.radio.switcher.

addfieldprefix, addruleprefix, addfilterprefix

Register a namespace for custom classes.

The life of a form

The Form class (Joomla\CMS\Form\Form) is what you hold in PHP. A model, a controller or a plugin obtains one from the form factory, loads XML into it, binds data to it, and asks it to render, filter or validate. Understanding the order of those steps explains most "my field disappeared" and "my value is ignored" reports.

sequenceDiagram participant B as Browser participant C as Controller participant M as Model participant F as Form object participant P as Plugins B->>C: GET edit trail C->>M: getForm(data, loadData) M->>F: createForm(name, options) and loadFile(trail.xml) M->>P: onContentPrepareForm(form, data) M->>F: bind(loadFormData()) F-->>B: renderField per field (HTML) B->>C: POST jform[...] with token C->>M: validate(form, data) M->>P: onContentBeforeValidateData M->>F: process(data) = filter, validate, postProcess alt invalid F-->>C: false and getErrors() C->>B: redirect back, data kept in session else valid F-->>C: filtered array C->>M: save(validData) end

The sequence shows that there are two Form instances in a typical edit-and-save round trip, one per request. The form that renders the page is gone when the POST arrives, so the controller asks the model for a fresh one and the model rebuilds it with the same XML and the same plugin changes. This is why a plugin that adds a field must do it on every request, not just when the edit page is displayed.

Creating a form

Since Joomla 4 the supported way to create a Form is the form factory service, FormFactoryInterface, rather than Form::getInstance(). A form needs a name that is unique among the forms on the page and an options array whose most important key is control, the prefix of the input names:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Site\Service;

use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFactoryInterface;

final class ReportFormBuilder
{
    public function __construct(private readonly FormFactoryInterface $formFactory)
    {
    }

    public function build(): Form
    {
        $form = $this->formFactory->createForm('com_tallpine.report', ['control' => 'jform']);
        $form->loadFile(JPATH_COMPONENT_SITE . '/forms/report.xml');

        return $form;
    }
}

loadFile() reads the XML into a SimpleXMLElement and calls load(); both accept $reset / $replace (when a field with the same group and name already exists, true replaces it and false keeps the old one) and an $xpath to import only a part of the document. The com_menus component uses $xpath to add menu-item options from layout XML files. In code that is not inside a model you can obtain the factory from the container, Factory::getContainer()->get(FormFactoryInterface::class), but constructor injection (see Namespaces, autoloading and dependency injection) is better, and MVCFactory already hands every model a form factory.

Older tutorials and the Joomla 3-era JForm examples write JForm::getInstance('sample', $path, ['control' => 'myform']). Form::getInstance() still exists and works, and the Manual’s sample components use it, but it keeps instances in a static cache keyed by name, which makes tests and multiple instances awkward. The J* class aliases are available only through the Behaviour - Backward Compatibility 6 plugin in 6.x, so new code should import the namespaced class.

Binding, rendering and reading values

bind($data) stores an array or object inside the Form instance. At render time the stored value for a field name overrides the default attribute. setValue($name, $group, $value) changes a single value after binding and is the right tool when a plugin must force a value, because changing the default attribute would be overridden by bound data.

$form->bind(['title' => 'Cima Verde loop', 'distance_km' => 12.5]);

// one field: label + input wrapped in the layout that the field type chooses
echo $form->renderField('title');

// a whole fieldset (all fields of 'details')
echo $form->renderFieldset('details');

// pieces, for custom markup
echo $form->getLabel('distance_km');
echo $form->getInput('distance_km');

// values and metadata
$distance = $form->getValue('distance_km');
$groups   = $form->getFieldsets();
$params   = $form->getGroup('params');

renderField() produces a label, an input and a wrapping <div> using the joomla.form.renderfield layout, which a template can override. Remember that the output must sit inside a <form> element with method="post" and a submit control; a form object renders fields, not the form tag.

Filtering, validating and post-processing

Form::process($data) is the one-call version of the server-side pipeline, and it is what FormModel::validate() calls:

  1. filter($data) runs each field’s filter on the value that was posted and builds a new, clean array. Fields missing from the input are not added. A field whose setup() returns false (the field decides not to render, for example because the user lacks a capability) is dropped from the output.

  2. validate($data) checks the filtered data field by field: the disabled-field check, the required check and the validate rule. Errors are collected in the form and returned by getErrors().

  3. postProcess($data) gives each field type a last chance to transform the value, for example to turn a list of subform rows into the stored structure.

A direct use, outside a model, looks like this (it is the pattern the Manual’s sample components use):

$data     = $app->getInput()->post->get('jform', [], 'array');
$filtered = $form->filter($data);

if (!$form->validate($filtered)) {
    foreach ($form->getErrors() as $error) {
        $app->enqueueMessage($error->getMessage(), 'error');
    }

    $form->bind($filtered); // keep what the user typed

    return false;
}

Two details matter. validate() returns true or false and puts \Exception objects into getErrors(); it throws a RuntimeException for a disabled field that was nevertheless posted, and it adds an error for a value that arrives for a field that is not part of the rendered form. That second check is a deliberate defence against mass-assignment: the server refuses data that the form definition would never have produced.

The 80 standard field types

The Developer Manual’s page Standard Form Fields lists every field type that ships in the Joomla\CMS\Form\Field namespace. In the 6.1 documentation there are exactly 80. The groups below are by purpose; each name is the value of the type attribute.

Purpose Field types

Text and numbers

text, textarea, password, email, url, telephone, number, integer, range, meter, color, hidden

Choices

list, groupedlist, radio, radiobasic, checkbox, checkboxes, combo, predefinedlist, sql, status

Date and time

calendar, date (6.0), datetime (6.0), time, timezone, lastvisitdaterange, registrationdaterange

Files and media

media, accessiblemedia, file, filelist, folderlist, imagelist

Content and structure

category, tag, aliastag, author, contenttype, contenthistory, contentlanguage, language, frontendlanguage, ordering, menu, menuitem, editor, captcha

Users and access

user, useractive, userstate, usergrouplist, accesslevel, rules, transition, workflowcondition, workflowstage, workflowcomponentsections

Extensions and layout

components, componentlayout, modulelayout, moduleposition, moduleorder, moduletag, chromestyle, headertag, templatestyle, plugins, pluginstatus, modalselect

System

cachehandler, sessionhandler, databaseconnection, redirectstatus, schemaorgcomponentssections, limitbox

Layout-only

note (6.0), spacer, subform

The grouping above is this page’s, not the Manual’s, which lists the fields alphabetically. The type names are the Manual’s slugs; in particular the sorting field’s page is ordering (the Manual titles it "Order"), and the radio field has a plain variant, radiobasic.

A few types deserve extra notes because they are used in nearly every component.

Text, number and telephone fields

text is the base for most input types and takes size, maxlength, hint, pattern, autocomplete, inputmode and dirname. number and range accept min, max and step; integer generates a list of options from first to last with a step, which is a select, not a text input. Joomla 6.0 also made note a form field: a read-only block of formatted text that can carry a heading, a description and the close button, useful for explaining a group of options inside an administrator screen without writing a layout.

Type email automatically adds validate-email for client-side checking and, with validate="email", the server-side rule. Type telephone has no built-in server filter; use filter="tel" and validate="tel" explicitly.

Calendar, Date and Datetime

calendar is the long-standing date picker. It has format (a strftime-style pattern), showtime, timeformat, todaybutton, weeknumbers, filltable, minyear and maxyear, and a filter attribute whose special values user_utc and server_utc convert a date from the user’s or the server’s time zone to UTC on save. That conversion is why a re-displayed form after a validation error needs the filtered value of a calendar field: the controller applies the filter before storing posted data back in the session.

Joomla 6.0 added two more, based on the browser’s native pickers: date (<input type="date">, with min and max in Y-m-d form) and datetime (<input type="datetime-local">, with min, max and a filter). In the source both classes are marked @since 6.0.0. They suit simple "closed from" dates such as the Tallpine closure period, while calendar is still the right choice when you need the Joomla time-zone filters, a custom format or the picker’s today and clear buttons. Older books written for Joomla 3, 4 or 5, including the Joomla 5 development book Developing Extensions for Joomla! 5 (Packt, 2023), predate these two types and know only calendar.

Category, tag and user fields

category lists categories of one extension (extension="com_tallpine"), accepts published, language, action (only categories on which the user has an ACL action such as core.create), show_root and allowAdd for the inline "create new" option. tag is a fancy-select tag input with mode="ajax" or mode="nested", custom="allow" and multiple. user renders a modal user picker and usergrouplist and accesslevel render group and level selectors. Most of these hit the database or ACL; they need the correct component context, which is why the same XML works in the administrator edit form, a custom field and a module parameter.

Media, file and editor fields

media opens the Media Manager (see Media Manager) with types, preview, directory and schemes attributes; Joomla 6.1 added audio, video and document variants of the media field, described in the custom-fields page. accessiblemedia adds the alternative-text and caption inputs. file is a real <input type="file"> that requires enctype="multipart/form-data" on the <form>, an accept attribute for the browser and server-side checks with the upload helper; Joomla does not validate the file contents for you. editor renders the editor selected by the user or by the field’s editor attribute, with buttons, hide and filter.

SQL and sorting fields

sql builds a list from a query. It takes query, key_field and value_field, and Joomla runs the query without placeholders, so the XML author, not the user, must control it. ordering shows items in a sort order and needs the table name and the order column. A sql field whose query contains the current user’s input is an injection hole: write a custom ListField with a bound query instead (see below).

Filters

A filter cleans a value before validation. Every field has the filter attribute; the rules are in the filter() method of FormField.

  • With no filter attribute a text-like field passes its value through Joomla’s default InputFilter, which removes unsafe tags and attributes. The hard-coded defaults are not configurable in the Global Configuration. This is the default when you do nothing, and it is much safer than raw.

  • A filter attribute replaces the default completely. A value that is empty and not required returns an empty string without running the filter.

  • A filter value containing :: calls a static PHP method, filter="Tallpine\Helper\TrailHelper::normaliseSlug". The method receives the value. Joomla 6 still resolves a legacy JComponentHelper::... call only through the BC plugin and throws an exception otherwise.

  • A filter that names a class registered via addfilterprefix is loaded and its filter() method is called.

  • Otherwise a plain PHP function name such as trim is called with the value.

  • As the last step the name is handed to InputFilter::clean($value, $filter), which understands the classic types integer, int, uint, float, bool, word, alnum, cmd, base64, string, html, path, username, array and raw.

The Manual documents a set of ready-made filter classes, available as filter="..." values:

Filter Effect

intarray

Applies PHP intval to each element and discards other characters. Use it for lists of ids.

raw

No filtering at all. Use only where the stored data is never output unescaped, and only for fields that the current user is trusted to fill with markup.

rules

Filters permission ("rules") fields.

safehtml

Like the default filter but can be configured to block more tags.

tel

Normalises telephone number formats.

unset

Removes the value and returns null, so the field is never stored.

url

Removes characters that are not valid in a URL.

A custom filter

A custom filter is a class that implements Joomla\CMS\Form\FormFilterInterface, which has a single method. Tallpine uses one to normalise the free-text alias of a trail:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Filter;

use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFilterInterface;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\Registry\Registry;

\defined('_JEXEC') or die;

final class TrailAliasFilter implements FormFilterInterface
{
    public function filter(
        \SimpleXMLElement $element,
        $value,
        $group = null,
        ?Registry $input = null,
        ?Form $form = null
    ) {
        $value = trim((string) $value);

        if ($value === '' && $input !== null) {
            // fall back to the title when the alias is left empty
            $value = (string) $input->get('title', '');
        }

        return OutputFilter::stringURLSafe($value);
    }
}
<field name="alias" type="text" label="JFIELD_ALIAS_LABEL"
       filter="TrailAlias"
       addfilterprefix="Tallpine\Component\Tallpine\Administrator\Filter" />

The filter value TrailAlias is resolved as <prefix>\TrailAliasFilter, because the loader appends Filter to the name. The $input argument is the whole posted data set as a Registry, which lets a filter read other fields, as above. Filters must be pure: they are called again whenever a controller needs the processed data of a failed submission.

Validation rules

A validation rule decides whether a filtered value is acceptable. It is named by the validate attribute and lives in Joomla\CMS\Form\Rule (the core files are in libraries/src/Form/Rule). The server-side checks the Manual describes are these.

Rule What it checks

boolean

1, 0, true or false.

calendar

now or any string accepted by PHP’s DateTime.

captcha

The answer of the configured CAPTCHA; optional plugin and namespace attributes force a particular plugin.

color

Three or six hexadecimal characters.

cssidentifier, cssidentifiersubstring

A valid CSS identifier or part of one.

email

A valid address. Attributes: tld, validDomains (default com_users.domains), multiple, unique.

equals, notequals

Compares with another field named in the field attribute (used for password confirmation).

exists

The value exists in a table: exists_table and exists_column (default id).

filepath

A path that stays inside the Joomla folder; existence is not checked. exclude takes a pipe-separated list.

folderpathexists

The folder exists relative to the Joomla root.

modulelayout

A valid module layout name.

number

A float between optional min and max.

options

The value is one of the field’s options.

password

The value meets the site’s password requirements.

regex

Uses validate_regex and validate_modifier.

rules

All posted permission actions are valid for this field.

showon

The showon attribute has a valid format.

subform

Validates each subform field with its own rule.

tel

A telephone number; plan accepts northamerica, us, International, int, missdn or IETF.

time

A valid time, limited by min, max and step.

url

A valid URL with XSS checks; attributes disableXssCheck, schemes and relative.

userid

A valid user id.

username

A username that is unique in #__users.

Two checks run regardless of the rule: a required field must have a value, and a disabled field must not be present in the request. A failed rule shows the generic "Invalid field" message unless the field has a message attribute (a language key), or the rule sets its own message.

The rule loader fills in services before it calls test(). A rule that implements DatabaseAwareInterface receives the database through setDatabase() (the standard DatabaseAwareTrait provides it), and one that implements CurrentUserInterface receives the user. That makes rules injectable without Factory::getDbo(), which no longer exists in Joomla 6.

A custom rule with a regular expression

The shortest rule extends Joomla\CMS\Form\FormRule and sets $regex. This one accepts a GPX file name for the Tallpine gpx-file field:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Rule;

use Joomla\CMS\Form\FormRule;

\defined('_JEXEC') or die;

final class GpxFileNameRule extends FormRule
{
    protected $regex = '^[a-z0-9][a-z0-9_-]{0,63}\.gpx$';

    protected $modifiers = 'i';
}

FormRule::test() wraps the expression in non-printable delimiters, adds the u modifier when PCRE has Unicode properties, and returns true when preg_match succeeds. An empty $regex throws an UnexpectedValueException.

A custom rule with a test method

When a regular expression is not enough, implement test(). The signature, in the 6.2 source, is test(\SimpleXMLElement $element, $value, $group = null, ?Registry $input = null, ?Form $form = null). Return true for a valid value and false otherwise. The rule below checks that a trail’s elevation gain is plausible for its distance, using another field of the form; it also demonstrates reading a custom attribute from the XML element and setting a dynamic message.

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Rule;

use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormRule;
use Joomla\CMS\Language\Text;
use Joomla\Registry\Registry;

\defined('_JEXEC') or die;

final class ElevationRule extends FormRule
{
    public function test(
        \SimpleXMLElement $element,
        $value,
        $group = null,
        ?Registry $input = null,
        ?Form $form = null
    ) {
        if ($value === '' || $value === null) {
            return true; // 'required' is a separate check
        }

        if (!is_numeric($value) || (float) $value < 0) {
            return false;
        }

        // custom attribute: <field ... metres_per_km="400" />
        $limit    = (float) ($element['metres_per_km'] ?? 400);
        $distance = $input !== null ? (float) $input->get('distance_km', 0) : 0.0;

        if ($distance > 0 && (float) $value > $distance * $limit) {
            $message = Text::sprintf('COM_TALLPINE_ERROR_ELEVATION_IMPLAUSIBLE', (int) $value, $distance);

            if (isset($element['message'])) {
                $element->attributes()->message = $message;
            } else {
                $element->addAttribute('message', $message);
            }

            return false;
        }

        return true;
    }
}
<field name="elevation_gain" type="number" label="COM_TALLPINE_FIELD_ELEVATION_LABEL"
       min="0" step="10" validate="Elevation" metres_per_km="400"
       addruleprefix="Tallpine\Component\Tallpine\Administrator\Rule" />

The rule name is resolved the same way as filters: Elevation becomes <prefix>\ElevationRule. The Manual’s own example uses validate="custom" and CustomRule; the name is just a convention. The $group argument is the field-name group, so with a group bar and a field foo the HTML name is bar[foo]. In a form that nests field groups, read other fields from $input with their dotted path (params.show_map), because $input is a Registry.

A rule that must query the database follows the DI route. Because the form loader injects the database into any rule that implements DatabaseAwareInterface, the class only has to use the trait:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Rule;

use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormRule;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;

\defined('_JEXEC') or die;

final class UniqueTrailRule extends FormRule implements DatabaseAwareInterface
{
    use DatabaseAwareTrait;

    public function test(
        \SimpleXMLElement $element,
        $value,
        $group = null,
        ?Registry $input = null,
        ?Form $form = null
    ) {
        $id    = (int) ($input?->get('id') ?? 0);
        $title = (string) $value;
        $db    = $this->getDatabase();

        $query = $db->createQuery()
            ->select('COUNT(*)')
            ->from($db->quoteName('#__tallpine_trails'))
            ->where($db->quoteName('title') . ' = :title')
            ->where($db->quoteName('id') . ' <> :id')
            ->bind(':title', $title)
            ->bind(':id', $id, ParameterType::INTEGER);

        return (int) $db->setQuery($query)->loadResult() === 0;
    }
}

Notice the prepared statement: the posted title is never concatenated into the SQL. See Joomla database API for the query builder and Security in extensions for the injection checklist.

Rule and filter interplay

The pipeline order is fixed: filter, then validate. A filter can therefore mask an invalid value (an intarray filter turns abc into an empty list that required then rejects, but integer turns abc into 0, which may pass). Validation sees what the filter produced, so choose a filter that does not hide errors you want to report, and let a rule reject what a filter cannot repair.

Client-side validation

Client-side validation gives instant feedback and nothing more. The Manual says plainly that it is "not sufficient to make your website secure". Three steps enable it:

  1. Load the script through the Web Asset Manager (see Web Asset Manager): $wa->useScript('form.validate'), which delivers media/system/js/fields/validate.js.

  2. Add the class form-validate to the <form> element.

  3. Add a validate-... class, or the matching field type, to each field.

<?php
// administrator/components/com_tallpine/tmpl/trail/edit.php

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;

\defined('_JEXEC') or die;

/** @var \Tallpine\Component\Tallpine\Administrator\View\Trail\HtmlView $this */
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('keepalive')
   ->useScript('form.validate');
?>
<form action="<?php echo Route::_('index.php?option=com_tallpine&layout=edit&id=' . (int) $this->item->id); ?>"
      method="post" name="adminForm" id="adminForm" class="form-validate" enctype="multipart/form-data">
    <?php echo $this->form->renderFieldset('details'); ?>
    <input type="hidden" name="task" value="">
    <?php echo HTMLHelper::_('form.token'); ?>
</form>

The built-in classes are validate-username, validate-password, validate-numeric and validate-email, whose regular expressions are in media/system/js/fields/validate.js. A field with type="email" gets validate-email automatically (but still needs the script and the form class), and required="true" makes the validator check that a value was entered. A pattern="[^x]+" attribute on a text or telephone field adds a JavaScript regular expression, again only client-side.

Validation runs inside Joomla.submitbutton(task). Toolbar save buttons carry a form-validation class, so the helper calls submitbutton with validation switched on; cancel buttons call it without. A hand-written submit button must call Joomla.submitbutton('trail.apply') or an equivalent.

A custom client-side validator

The validator is a function registered with document.formvalidator.setHandler; the class on the field is validate- plus the handler name. This one rejects Tallpine trail codes that are not of the form TP-123:

// media/com_tallpine/js/trail-code-validator.js
window.addEventListener('load', () => {
  document.formvalidator.setHandler('trailCode', (value) => /^TP-\d{1,4}$/.test(value));
});
{
  "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
  "name": "com_tallpine",
  "version": "1.0.0",
  "assets": [
    {
      "name": "com_tallpine.validate-trail-code",
      "type": "script",
      "uri": "com_tallpine/trail-code-validator.js",
      "dependencies": ["form.validate"],
      "attributes": { "defer": true },
      "version": "1.0.0"
    }
  ]
}
<field name="trail_code" type="text" label="COM_TALLPINE_FIELD_CODE_LABEL"
       class="validate-trailCode"
       data-validation-text="COM_TALLPINE_ERROR_TRAIL_CODE"
       validate="TrailCode" />

The view then calls $wa->useScript('com_tallpine.validate-trail-code'). The data-validation-text attribute supplies the message. Always pair a custom JavaScript validator with a server-side rule that enforces the same constraint (here a regex rule), because the JavaScript is optional for an attacker.

Conditional display with showon

showon is a form attribute handled by a small piece of JavaScript (the showon script in media/system/js). It hides a field in the browser until another field has a particular value.

Syntax Meaning

showon="closed:1"

Show when the field closed has the value 1.

showon="closed:1,2"

Show when closed has 1 or 2 (a comma separates alternatives).

showon="closed!:1"

Show when closed is not 1.

showon="closure_reason!:"

Show when closure_reason is not blank.

showon="closed:1[AND]season:winter"

Show when both conditions hold.

showon="closed:1[OR]difficulty:hard"

Show when either condition holds.

The names refer to field names in the same form and are resolved against the control and group, so inside the params group, showon="show_map:1" finds jform[params][show_map]. For fields inside a subform, use the field id instead of the name, as the Joomla User Guide describes for custom fields. showon is purely presentational; there is a showon validation rule that only checks the syntax. The server still processes the hidden field if it arrives, so a field that must not be saved when hidden needs server-side logic (for example a filter="unset" that is applied from prepareTable(), or clearing the value in save()).

Custom field classes

When a standard field is not enough, write a PHP class. The base class is Joomla\CMS\Form\FormField (the file is libraries/src/Form/FormField.php), and you can also extend any standard field, since they all extend FormField.

The class is found through the prefix registered with addfieldprefix: type grade plus the prefix Tallpine\Component\Tallpine\Administrator\Field makes Joomla load Tallpine\Component\Tallpine\Administrator\Field\GradeField. Setting the $type property (protected $type = 'Grade';) is still common and sets the value getName() and the generated ids use, but the Manual notes that it is not required for namespaced classes.

The rendering protocol is:

  1. setup(SimpleXMLElement $element, $value, $group) receives the XML element, the bound value and the group, copies the attributes into properties, and returns false to make the field disappear.

  2. renderField() builds three parts, a label, an input and a wrapping <div>, by calling getLabel() and getInput() and passing them to the joomla.form.renderfield layout.

  3. getInput() is the only method a minimal custom field has to implement; it returns an HTML string.

  4. getLabel() returns the label HTML and rarely needs overriding.

  5. Standard fields pass their local data ($displayData) to layouts instead of echoing markup, so class, description and so on reach the template overrides.

A ListField subclass

The simplest and most useful custom field extends ListField and overrides getOptions(). The Tallpine difficulty grades come from a fixed list, translated, with a "choose a grade" placeholder:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Field;

use Joomla\CMS\Form\Field\ListField;
use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;

final class GradeField extends ListField
{
    protected $type = 'Grade';

    private const GRADES = [
        'easy'     => 'COM_TALLPINE_GRADE_EASY',
        'moderate' => 'COM_TALLPINE_GRADE_MODERATE',
        'hard'     => 'COM_TALLPINE_GRADE_HARD',
        'alpine'   => 'COM_TALLPINE_GRADE_ALPINE',
    ];

    protected function getOptions()
    {
        // options declared in the XML (<option> children) stay in front
        $options = parent::getOptions();

        foreach (self::GRADES as $value => $key) {
            $options[] = (object) [
                'value' => $value,
                'text'  => Text::_($key),
            ];
        }

        return $options;
    }
}

The Manual’s Custom3Field follows the same shape with array_merge(parent::getOptions(), $options), and its Custom4Field extends GroupedlistField and overrides getGroups() in the same way. ListField::getOptions() is protected in Joomla 6.2 (the Manual’s examples declare it public, which also works because PHP allows widening visibility). ListField also has addOption($text, $attributes = []) for options added at run time.

Because the options are plain objects, validate="options" on the field checks that the posted value is one of them; always add it for fields whose options are limited by security, such as a "category the user may use".

A field with a dynamic list: the trail wardens

A field that reads from the database should receive the database from the framework, not from a static factory. FormField implements DatabaseAwareInterface and uses DatabaseAwareTrait, so getDatabase() is available in every field class:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Field;

use Joomla\CMS\Form\Field\ListField;
use Joomla\Database\ParameterType;

\defined('_JEXEC') or die;

final class WardenField extends ListField
{
    protected $type = 'Warden';

    protected function getOptions()
    {
        $options = parent::getOptions();
        $db      = $this->getDatabase();
        $groupId = (int) $this->getAttribute('group_id', 0);

        $query = $db->createQuery()
            ->select([$db->quoteName('u.id', 'value'), $db->quoteName('u.name', 'text')])
            ->from($db->quoteName('#__users', 'u'))
            ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm'), $db->quoteName('m.user_id') . ' = ' . $db->quoteName('u.id'))
            ->where($db->quoteName('m.group_id') . ' = :gid')
            ->where($db->quoteName('u.block') . ' = 0')
            ->order($db->quoteName('u.name'))
            ->bind(':gid', $groupId, ParameterType::INTEGER);

        return array_merge($options, $db->setQuery($query)->loadObjectList());
    }
}

The Manual’s overview does not document getDatabase() on fields; it comes from DatabaseAwareTrait in the 6.2 source. If a test creates a field by hand, call setDatabase() first.

A FormField with its own input

For a field type that is not a list, extend FormField and implement getInput(). This one shows the number of metres and feet and uses a layout so the markup can be overridden:

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Field;

use Joomla\CMS\Form\FormField;

\defined('_JEXEC') or die;

final class ElevationField extends FormField
{
    protected $type = 'Elevation';

    protected $layout = 'com_tallpine.form.field.elevation';

    protected function getInput()
    {
        return $this->getRenderer($this->layout)->render($this->getLayoutData());
    }

    protected function getLayoutData()
    {
        $data         = parent::getLayoutData();
        $data['feet'] = is_numeric($this->value) ? (int) round((float) $this->value * 3.28084) : null;

        return $data;
    }
}
<?php
// layouts/com_tallpine/form/field/elevation.php (in the manifest's <layouts> folder)

\defined('_JEXEC') or die;

extract($displayData);

/**
 * @var string      $id
 * @var string      $name
 * @var string|null $value
 * @var bool        $required
 * @var int|null    $feet
 */
?>
<div class="input-group">
    <input type="number" name="<?php echo $name; ?>" id="<?php echo $id; ?>"
           value="<?php echo htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); ?>"
           class="form-control validate-numeric"<?php echo $required ? ' required' : ''; ?>>
    <span class="input-group-text">m</span>
    <?php if ($feet !== null) : ?>
        <span class="input-group-text"><?php echo (int) $feet; ?> ft</span>
    <?php endif; ?>
</div>

The Manual’s Custom2Field follows the same plan with protected $layout = 'custom2field' and a file layouts/custom2field.php that starts with defined('_JEXEC') or die; and extract($displayData);. Its closing reminder is important and easy to forget: include the layouts folder in the extension manifest, or the layout file is not copied by the installer and the field renders nothing (see Extension types, manifests and packaging). Always escape values in a custom getInput(); the base class does not do it for you.

Subform fields

subform embeds one form in another. With multiple="true" it becomes a repeatable group of rows, which is how core stores lists such as the menu item "link types" and the Tallpine waypoints of a trail. Joomla serialises the rows as JSON in a single column.

Attribute Meaning

formsource

Required. A form XML path relative to the Joomla root, a form name the factory can resolve, or an inline <form> element nested in the field.

multiple

Make the rows repeatable. Without it the subform appears once.

min, max

Minimum (default 0) and maximum (default 1000) number of rows.

layout

One of the layouts below.

buttons

The row buttons: any of add, remove, move. The empty string restores the default; none hides all.

groupByFieldset

Group the subform fields by their fieldset (repeatable, grid and table layouts).

validate

Set to Subform (the value is case-sensitive in the Manual) so each subform field is validated with its own rule. Without it the rules of the nested fields are not run.

default

A JSON string with the default rows.

The layouts are joomla.form.field.subform.default (single mode), joomla.form.field.subform.repeatable, joomla.form.field.subform.repeatable-table and, new in Joomla 6.1, joomla.form.field.subform.repeatable-grid, which renders each row as a grid with one cell per field.

<field
    name="waypoints"
    type="subform"
    label="COM_TALLPINE_FIELD_WAYPOINTS_LABEL"
    multiple="true"
    min="0"
    max="25"
    layout="joomla.form.field.subform.repeatable-grid"
    buttons="add,remove,move"
    validate="Subform"
>
    <form>
        <field name="label" type="text" label="COM_TALLPINE_WAYPOINT_LABEL" required="true" />
        <field name="altitude" type="number" label="COM_TALLPINE_WAYPOINT_ALTITUDE" min="0" max="5000" />
        <field name="has_water" type="radio" label="COM_TALLPINE_WAYPOINT_WATER"
               layout="joomla.form.field.radio.switcher" default="0">
            <option value="0">JNO</option>
            <option value="1">JYES</option>
        </field>
    </form>
</field>

The posted data is an array indexed by row keys (waypoints0, waypoints1, …​). filter() and validate() visit every row; the field’s filter() method loads a sub-form for each row and calls its filter(), and a default value given as a JSON string is decoded first (an invalid JSON string filters to null). When saving, convert the row array into the stored JSON (the AdminModel does this for you for params-like fields; for your own columns call json_encode in prepareTable() or save()).

The grid layout and the order event (6.1)

The grid layout and the subform-order-changed event are new in Joomla 6.1; the files are present in the 6.1.0 source tag and absent from 6.0.x. The event is a DOM CustomEvent dispatched by the joomla-field-subform web component after the user reorders a row, and it bubbles up from the subform element. Its detail carries row (the affected row element), fromPosition (the old index) and toPosition (the new index):

document.addEventListener('subform-order-changed', (event) => {
  const { row, fromPosition, toPosition } = event.detail;
  const subform = event.target;

  // renumber the visible step labels in the Tallpine waypoint editor
  subform.querySelectorAll('.subform-repeatable-group').forEach((group, index) => {
    const badge = group.querySelector('.waypoint-index');
    if (badge) {
      badge.textContent = String(index + 1);
    }
  });

  console.debug(`waypoint ${row.dataset.group} moved from ${fromPosition} to ${toPosition}`);
});

Load such a script through the Web Asset Manager with a dependency on webcomponent.field-subform. Row positions are renumbered by the component on save, so a listener is for display only.

Subform pitfalls

  • The name Subform in validate="Subform" matters. Without it, the nested required and validate attributes do nothing on the server.

  • A calendar field inside a repeatable subform has failed on a second save unless Show Time was set to Yes for it, and duplicate subform fields are removed on save with a warning in the 5.4 series and later; see Joomla custom fields for the details.

  • Inside a subform, showon must refer to the generated field id rather than the plain field name.

  • Files uploaded through a file field inside a multiple subform are posted under nested names, so the handling code must walk the $_FILES structure.

  • A very large max makes the page heavy: the web component clones the row template in the browser.

Loading and binding forms in models

In an MVC component the model owns the form. FormModel and AdminModel (both in Joomla\CMS\MVC\Model) share the FormBehaviorTrait, which provides loadForm(), loadFormData(), preprocessForm() and preprocessData(). A component overrides only two methods, getForm() and loadFormData().

<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Administrator\Model;

use Joomla\CMS\Form\Form;
use Joomla\CMS\MVC\Model\AdminModel;

\defined('_JEXEC') or die;

final class TrailModel extends AdminModel
{
    public $typeAlias = 'com_tallpine.trail';

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

        if (empty($form)) {
            return false;
        }

        // adjust the form for the current user
        if (!$this->getCurrentUser()->authorise('core.edit.state', 'com_tallpine')) {
            $form->setFieldAttribute('published', 'disabled', 'true');
            $form->setFieldAttribute('published', 'filter', 'unset');
        }

        return $form;
    }

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

        if (empty($data)) {
            $data = $this->getItem();
        }

        $this->preprocessData('com_tallpine.trail', $data);

        return $data;
    }

    protected function preprocessForm(Form $form, $data, $group = 'content')
    {
        // the form file is the only place the fields are declared; plugins in the
        // 'content' group are still allowed to change it via onContentPrepareForm
        parent::preprocessForm($form, $data, $group);
    }
}

The steps loadForm() performs, in order, are visible in the 6.2 source:

  1. Build a signature hash from the form source and the options (excluding load_data) and return the cached form if the same one was loaded before in this request, unless $clear is set.

  2. Register the component form and field paths: forms, models/forms, models/fields, model/form, model/field.

  3. Get the form factory (the model’s own, or the container’s) and call createForm($name, $options).

  4. Load the XML: if the source starts with < it is parsed as an XML string, otherwise loadFile() finds <source>.xml in the registered paths.

  5. When the option load_data is true, call loadFormData() to get the data.

  6. Call preprocessForm($form, $data), which dispatches onContentPrepareForm to the plugins of the model’s event group (the content group by default).

  7. Call $form->bind($data), so the bound values are in place after the plugins changed the form.

The control option decides the input names. By convention the controller reads $this->input->post->get('jform', [], 'array'); if the model’s control and the controller’s key disagree, the form posts values the controller never sees, which is the most common reason for "the form saves nothing" in new components. FormController::save() obtains the model’s form with $model->getForm($data, false), normalises the request data, calls $model->validate($form, $data) and, on failure, stores the unfiltered data in the user state (com_tallpine.edit.trail.data) so loadFormData() can re-fill the form.

The validate method of FormModel

FormModel::validate($form, $data, $group = null) dispatches onContentBeforeValidateData (with a Model\BeforeValidateDataEvent) so a plugin can adjust the posted data, calls $form->process($data, $group) and returns the filtered array or false. The older event onUserBeforeDataValidation is still dispatched when a listener exists but logs a deprecation and is removed in 7.0, so plugins should switch to onContentBeforeValidateData. After a false result the controller pushes the messages from getErrors() to the user.

Changing a form at run time

The Form class offers a small API to change a loaded form:

use Joomla\CMS\Form\Form;

// add or replace a field
$xml = new \SimpleXMLElement(
    '<field name="gpx_url" type="url" label="COM_TALLPINE_FIELD_GPX_URL" filter="url" validate="url" />'
);
$form->setField($xml, null, true, 'details');   // $group, $replace, $fieldset

// change a Joomla attribute (not an HTML attribute)
$form->setFieldAttribute('distance_km', 'max', '80');
$form->setFieldAttribute('title', 'hint', 'Cima Verde loop');

// change the value
$form->setValue('difficulty', null, 'moderate');

// remove
$form->removeField('closure_reason');
$form->removeGroup('params');

// load another XML file or string into the same form
$form->loadFile(JPATH_COMPONENT_ADMINISTRATOR . '/forms/trail_extra.xml', false);

setFieldAttribute() changes the Joomla attribute from the XML, for instance hint; whether that reaches the HTML depends on the field type. To change the value, use setValue(), because bound data overrides the attribute. setFields() adds several. The $replace argument decides what happens when a field with the same name and group exists.

Plugins that change forms: onContentPrepareForm

A plugin can add fields to any form whose model dispatches onContentPrepareForm. The system plugin fields that powers custom fields works this way, and so does every core plugin that adds tabs to the article edit screen. In Joomla 6.2 the plugin is a SubscriberInterface listener with the concrete event class (see Plugin development and events):

<?php

declare(strict_types=1);

namespace Tallpine\Plugin\Content\TallpineConditions\Extension;

use Joomla\CMS\Event\Model\PrepareFormEvent;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;

\defined('_JEXEC') or die;

final class TallpineConditions extends CMSPlugin implements SubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return ['onContentPrepareForm' => 'onContentPrepareForm'];
    }

    public function onContentPrepareForm(PrepareFormEvent $event): void
    {
        $form = $event->getForm();

        // only the Tallpine trail form, not every form in the site
        if ($form->getName() !== 'com_tallpine.trail') {
            return;
        }

        Form::addFormPath(__DIR__ . '/../../forms');
        $form->loadFile('conditions', false);
    }
}

$event->getForm() and $event->getData() are the getters of the event class. The form name is the first argument that the model passed to loadForm(), so a plugin identifies "its" form by comparing it. Forget this check and the plugin runs for every form on the site, including user registration and the login module.

A front-end form

Front-end forms follow the same model pattern but add the pieces that visitors, unlike administrators, need: a captcha, a CSRF token, a no-cache redirect and the Post/Redirect/Get dance. The Tallpine report a trail condition form is a FormModel with the form definition in components/com_tallpine/forms/report.xml:

<?xml version="1.0" encoding="UTF-8"?>
<form>
    <fieldset name="report">
        <field name="trail_id" type="sql" label="COM_TALLPINE_REPORT_TRAIL"
               query="SELECT id AS value, title AS text FROM #__tallpine_trails WHERE published = 1 ORDER BY title"
               required="true" validate="options" />
        <field name="reporter_email" type="email" label="COM_TALLPINE_REPORT_EMAIL"
               required="true" validate="email" />
        <field name="condition" type="list" label="COM_TALLPINE_REPORT_CONDITION"
               required="true" validate="options">
            <option value="clear">COM_TALLPINE_CONDITION_CLEAR</option>
            <option value="snow">COM_TALLPINE_CONDITION_SNOW</option>
            <option value="blocked">COM_TALLPINE_CONDITION_BLOCKED</option>
        </field>
        <field name="notes" type="textarea" label="COM_TALLPINE_REPORT_NOTES"
               rows="5" maxlength="1000" filter="string" />
        <field name="captcha" type="captcha" label="COM_TALLPINE_REPORT_CAPTCHA"
               validate="captcha" />
    </fieldset>
</form>
<?php

declare(strict_types=1);

namespace Tallpine\Component\Tallpine\Site\Controller;

use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;

final class ReportController extends BaseController
{
    public function submit(): bool
    {
        $this->checkToken(); // CSRF: the form includes HTMLHelper::_('form.token')

        $model = $this->getModel('Report', 'Site');
        $form  = $model->getForm(null, false);
        $data  = $this->input->post->get('jform', [], 'array');

        $valid = $model->validate($form, $data);

        if ($valid === false) {
            foreach ($model->getErrors() as $error) {
                $this->app->enqueueMessage($error instanceof \Exception ? $error->getMessage() : $error, 'warning');
            }

            $this->app->setUserState('com_tallpine.report.data', $data);
            $this->setRedirect(Route::_('index.php?option=com_tallpine&view=report', false));

            return false;
        }

        $model->save($valid);
        $this->app->setUserState('com_tallpine.report.data', null);
        $this->setRedirect(Route::_('index.php?option=com_tallpine&view=trails', false), Text::_('COM_TALLPINE_REPORT_THANKS'));

        return true;
    }
}

The captcha rule and field use the CAPTCHA plugin chosen in Global Configuration or in the field’s plugin attribute. The core reCAPTCHA plugin was removed in 5.0, and a Proof-of-Work captcha arrived in 6.1; install or enable the plugin you want before depending on it. The sql field above is acceptable because the query is a constant in the XML and the options rule checks that the posted value is one of the offered ids. See Component site views and routing for the view and the router.

Security

Forms are where untrusted data enters Joomla. These rules cover almost every form-related vulnerability.

  • Always validate on the server. The Manual’s own warning is that a request can be crafted without the browser, so required, pattern, min and max in the HTML mean nothing on their own. Use a validate rule or a filter that enforces the same constraint.

  • Choose the filter on purpose. The default InputFilter is a safe baseline for text. raw disables all filtering and should only appear on fields that trusted users fill, and only if the output layer escapes. safehtml is the choice for rich text from semi-trusted users; unset is the choice for fields the user must not set.

  • Disabled and hidden does not mean protected. Joomla rejects a disabled field that is posted, and ignores a value for a field that is not rendered; but a field hidden with showon or by CSS is still processed if it arrives, so a restricted field needs an ACL check, for example in getForm() as shown above.

  • CSRF token. Render HTMLHelper::_('form.token') and call $this->checkToken() in the controller. The core controllers do it; hand-written controllers must.

  • Escape on output. Filtering cleans stored data; output still needs htmlspecialchars or a layout that escapes. Custom getInput() implementations are the usual place where this is forgotten.

  • File fields. Check size, extension and MIME type, store files outside the web root or in a folder without script execution, and never trust the original file name.

  • Mass assignment. Bind only the filtered result of validate() to the table. Binding the raw $_POST array bypasses the form definition entirely.

  • Queries in fields and rules. sql fields and custom lists must not include user input in the query text; use bound parameters and whereIn().

  • Error messages. Messages from a rule are shown to the user, so do not include internal details or other users' data.

Common errors and pitfalls

Symptom Cause and fix

Client-side validation does nothing

The script is not loaded (useScript('form.validate')), the <form> lacks form-validate, or the field lacks a validate-* class.

rule 'x' missing exception

The validate name has no class: forgot addruleprefix, wrong namespace, or the class name does not end in Rule.

Custom field renders as a text box

The type did not resolve to a class: missing addfieldprefix, wrong namespace, or a PSR-4 mapping in the manifest that does not match the folder.

Layout file for a custom field is missing after install

The <layouts> folder is not in the manifest.

Form saves nothing

control in loadForm() and the key the controller reads (jform) are different, or the model’s loadForm() returns a cached instance with different options.

Edited value comes back after a failed save

The failed data is stored in the user state; loadFormData() must read it before the database row, as in the example.

showon never fires

The condition names a field that is in another group or a subform; name a field from the same group, or use the field id.

A removed field reappears

onContentPrepareForm plugins re-add it; remove it after the plugins, or from a plugin with a higher order.

Values appear unfiltered

filter="raw", a callback that returns the input unchanged, or a custom filter that forgot to return a value.

Subform rules ignored

validate="Subform" is missing on the subform field.

Disabled field rejected

A disabled field cannot be posted; use readonly if the value must be submitted, or remove the field from the data in the model.

Date shifts by the time-zone offset after an error

The posted calendar value was stored in the session without its UTC filter. Use the filtered value, as the core controller does.

Two forms on a page interfere

The form names are not unique, or both use the same control.

Version notes and book corrections

Version Change

4.0

Namespaced form classes; FormFactoryInterface as the supported way to create a form; FormFilterInterface for filter classes; Form::process() combining filter, validate and post-process; the description of a field changed from a tooltip to inline help.

4.2

Multi-factor authentication replaced TFA in core. Its forms are ordinary forms (a plugin supplies the fields), which shows how far the form API is meant to be extended.

5.0

Concrete event classes for plugins, so onContentPrepareForm listeners receive a PrepareFormEvent with getForm() and getData() instead of an argument array. The ES5 assets were removed.

6.0

date, datetime and note field types added. JPATH_PLATFORM is no longer defined, so form files and layouts use defined('_JEXEC') or die;. The J* aliases (JForm, JFormField, JFormRule) are only available through the Behaviour - Backward Compatibility 6 plugin.

6.1

The repeatable-grid subform layout and the subform-order-changed event. A Proof-of-Work captcha plugin. Audio, Video and Document variants of the media field.

7.0 (planned)

onUserBeforeDataValidation is removed in favour of onContentBeforeValidateData; automatic detection of the $app and $db plugin properties goes away, so plugins and rules must use getApplication(), getDatabase() and the Aware traits.

Where older material differs from the 6.x documentation, the corrections are these.

  • Joomla 3 era books, wiki pages and many blog posts use JForm, JFormField, JFormRule, JFormHelper, JHtml::_('form.token'), JFactory::getApplication()->input and JFactory::getDbo(). Use the namespaced classes Joomla\CMS\Form\*, the container, and the Aware traits; the aliases exist only in BC6.

  • Older material shows field classes in models/fields, rules in models/rules and JFormHelper::addFieldPath(). Namespaced extensions register a prefix with addfieldprefix, addruleprefix and addfilterprefix, and the lookup path registration in loadForm() remains only for old-style components.

  • Developing Extensions for Joomla! 5 (Packt, 2023) covers XML forms, field types and custom fields on the Joomla 5.0 baseline. For 6.x, add the date, datetime and note fields (6.0), the grid layout and order event of subforms (6.1), the BC6 note for the J* aliases, and replace $db and $app as plugin properties (auto-detection ends in 7.0) with getDatabase() and getApplication().

  • Joomla! 4 Masterclass (Packt, 2023) treats custom fields from the user’s side (Joomla 4.x, which reached end of life in October 2025). It does not discuss the form API, filters, rules or custom field classes, which this page covers.

  • Documentation at docs.joomla.org for JForm and field types describes Joomla 1.5 to 3.x and marks itself obsolete; the Developer Manual at manual.joomla.org is the current reference, although its custom-field pages do not yet document every method (getInput() and getLabel() details, layout locations), which is why the 6.2-dev source tree is cited here.

  • The Manual’s text for validate() calls the filter and validation mechanism by their separate names; the source shows that Form::process() runs filter, validate and post-process in that order, and that filtering happens first.

Quick reference

# find every form definition and every custom field class in a component
find administrator/components/com_tallpine -name '*.xml' -path '*forms*'
find administrator/components/com_tallpine/src -path '*Field*' -name '*Field.php'

# check the XML is well formed
xmllint --noout administrator/components/com_tallpine/forms/trail.xml

# lint the PHP classes of a component with the house toolchain
vendor/bin/phpcs --standard=PSR12 administrator/components/com_tallpine/src
vendor/bin/phpstan analyse administrator/components/com_tallpine/src

For the PHP language features used throughout (namespaces, constructor promotion, readonly properties), see PHP namespaces and autoloading. For where forms fit into the whole component, see Component MVC in the administrator, Categories, custom fields and workflow integration and ACL in extensions. A module’s parameter form is the same machinery with a different manifest tag (see Module development), and a Web Services API endpoint reuses the model’s form for validation (see Web Services API).