Form API
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
The Form API is the part of Drupal that builds, renders, validates and processes every HTML form: the login form, the content editing forms, the administration screens of core and contributed modules, and the forms a custom module adds. A form is described by a nested PHP array (the form array, which is a superset of the render array described in Render API and cache metadata); Drupal turns it into HTML, adds the hidden fields that identify and protect the form, and on submission rebuilds the same array to decide whether the submitted values are acceptable before it calls the submit handlers.
This page was checked against the official documentation, the Drupal 11.x API reference and the relevant change records on
2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see
Requirements and hosting for the baseline). The examples belong to the
fictitious Meridian Museum site from the sub-section index and extend the meridian_collection
module with a settings form, a confirmation form, a multi-step visitor enquiry form, a dependent select box built twice
(with #ajax and with HTMX) and a form alter hook. Drupal 10 reaches end of life on 2026-12-09, so new sites should start
on Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07 and is not yet released; the Drupal 12 remarks below
describe scheduled changes that should be re-checked at the final release.
Who uses the Form API, and why
-
The module developer writes form classes for settings pages, confirmation steps, wizards, search and filter forms, and visitor-facing forms, and alters forms that other modules provide.
-
The site builder meets the Form API only through its results: the configuration form of a module, the "Are you sure?" page before a deletion, and the fields that appear or disappear as an option changes.
-
The themer restyles forms through form element templates and theme suggestions (see Template suggestions and preprocess) and may alter a form in a theme.
-
The administrator and security reviewer cares that every form carries a CSRF token, that access is checked on the route and that submitted values are validated on the server.
Using the Form API is not optional for anything that accepts input in Drupal. Writing raw <form> markup in a Twig template
bypasses the token, the validation, the cache metadata and the alter hooks, and is the usual cause of both security
reports and "the form does not work behind the page cache" problems.
Version history
-
Drupal 8.0 introduced the object-oriented form classes (
FormBase,ConfigFormBase,ConfirmFormBase) and routes that point at them with the_formkey. -
Drupal 10.1 added autowiring aliases for core services.
-
Drupal 10.2 added the
#config_targetproperty toConfigFormBaseforms, theConfigTargethelper class and constraint-based validation of the submitted configuration. The same release made the$typedConfigManagerconstructor argument ofConfigFormBaseavailable (optional in 10.2, required from 11.0). Form classes can useAutowireTraitand#[Autowire]constructor attributes from 10.2. -
Drupal 11.1 introduced object-oriented hook implementations with
#[Hook], including the form alter hooks. -
Drupal 11.2 added HTMX as a core dependency and ordering attributes for hook implementations.
-
Drupal 11.3 made HTMX a supported part of the Ajax subsystem: the
\Drupal\Core\Htmx\Htmxclass,FormBuilderawareness of HTMX requests, theHtmxRequestInfoTraitonFormBase, and thedrupal_htmxwrapper format.#ajaxkeeps working; the form builder treats both kinds of request alike. -
Drupal 12 moves to htmx 4. Request headers and several
Htmxmethods change (see HTMX in core), andFormBuilderInterface::HTMX_REQUESTis deprecated for removal in Drupal 13.
Older books describe an earlier state. Drupal 10 Development Cookbook (2023) covers the Form API well as a learning
sequence (a FormBase class, a route with _form, validation, submission, a ConfigFormBase settings page), but it
targets Drupal 10.0 on PHP 8.1: its ConfigFormBase examples pass only the config factory to the parent constructor and
copy each value to the configuration object by hand in submitForm() (since 11.0 the parent constructor also needs
the typed config manager, and #config_target removes the copying), its services come from \Drupal:: calls or a
hand-written create() rather than autowiring, its form alter hooks are .module functions rather than #[Hook]
methods, and AJAX is the only dynamic technique it knows. Drupal 10 Masterclass (2023) touches forms only briefly and
predates HTMX in core entirely. Where those books and the current documentation disagree, the documentation described
here wins.
The form lifecycle
The official documentation describes the workflow in four stages: build and prepare (the form function runs and the alter
hooks are called), recursive building (the #process and #after_build callbacks turn the form array into the final
render array), validation, and submission. When a submitted form is processed, Drupal builds it again first, so the
elements that are present decide which submitted values count.
The main extension points, in the order they run:
| Point | Purpose |
|---|---|
|
Returns the unique machine name of the form. It must be unique across the site and must not equal the name of a theme. Prefix it with the module name. |
|
Returns the form array. Anything that depends on the request, the route parameters or the stored state belongs here. |
Alter hooks |
|
|
Per-element callbacks that expand an element (for example a |
|
Validation: first each element’s own callbacks, then the form-level callbacks. |
|
Submission. A button can carry its own |
What FormBase gives you
FormBase implements FormInterface and supplies the plumbing most forms need. According to the API reference it uses
AutowireTrait, DependencySerializationTrait, LoggerChannelTrait, MessengerTrait, RedirectDestinationTrait,
StringTranslationTrait and (from 11.3) HtmxRequestInfoTrait. In practice that means $this->t() translates,
$this->messenger() shows a status message, $this->logger('channel') returns a logger, $this->config('name')
returns a read-only configuration object and $this->redirect() builds a redirect response. validateForm() has an
empty default implementation, so a form only overrides it when it has rules of its own.
FormBase also keeps lazy accessors such as currentUser(), getRequest() and getRouteMatch() that call \Drupal::
static methods. They exist for backwards compatibility; new code injects the services it needs instead (see
Dependency injection in forms).
Defining and routing a form
A form is displayed through a route whose defaults name the class in the _form key. The route supplies the title and the
access requirement; Drupal instantiates the class, calls buildForm() with any route parameters and renders the result.
# meridian_collection.routing.yml
meridian_collection.settings:
path: '/admin/config/content/meridian-collection'
defaults:
_form: '\Drupal\meridian_collection\Form\SettingsForm'
_title: 'Meridian collection settings'
requirements:
_permission: 'administer meridian collection settings'
meridian_collection.sync_reset:
path: '/admin/config/content/meridian-collection/reset'
defaults:
_form: '\Drupal\meridian_collection\Form\SyncResetConfirmForm'
_title: 'Reset the collection sync'
requirements:
_permission: 'administer meridian collection settings'
meridian_collection.enquiry:
path: '/collection/enquiry'
defaults:
_form: '\Drupal\meridian_collection\Form\ObjectEnquiryForm'
_title: 'Ask about an object'
requirements:
_permission: 'access content'
meridian_collection.gallery_finder:
path: '/collection/find'
defaults:
_form: '\Drupal\meridian_collection\Form\GalleryFinderForm'
_title: 'Find an object in the galleries'
requirements:
_permission: 'access content'
Source: Introduction to Form API
The administration screen also needs a menu link, a permission and, optionally, a configure key in the module’s
info.yml file so that the Extend page shows a "Configure" link:
# meridian_collection.links.menu.yml
meridian_collection.settings:
title: 'Meridian collection'
description: 'Configure the synchronisation of collection objects.'
parent: system.admin_config_content
route_name: meridian_collection.settings
# meridian_collection.permissions.yml
administer meridian collection settings:
title: 'Administer Meridian collection settings'
description: 'Change the synchronisation settings. Grant to trusted roles only.'
restrict access: true
# meridian_collection.info.yml (excerpt)
name: 'Meridian collection'
type: module
description: 'Collection objects, synchronisation and the related forms.'
core_version_requirement: ^11.1
package: Meridian
configure: meridian_collection.settings
Access is decided on the route (_permission, _entity_access, _custom_access), before the form class is built. See
Access control and permissions in code and
Routing and controllers for the requirement keys. Hiding an element with
#access only removes it from this form; it is not a substitute for a route requirement.
A form can also be displayed outside a route: a block, a controller or a service obtains the render array with
$this->formBuilder->getForm(ObjectEnquiryForm::class) from an injected FormBuilderInterface. A form displayed that way
inherits the cacheability of its surroundings, and the token described in CSRF protection still applies.
A basic form: the visitor search by accession number
The smallest useful form extends FormBase and implements getFormId(), buildForm() and submitForm(); most also
implement validateForm():
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Looks up a collection object by its accession number.
*/
class AccessionLookupForm extends FormBase {
public function getFormId(): string {
return 'meridian_collection_accession_lookup';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['accession_number'] = [
'#type' => 'textfield',
'#title' => $this->t('Accession number'),
'#description' => $this->t('For example MM.1987.0042.'),
'#required' => TRUE,
'#maxlength' => 32,
'#size' => 20,
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Look up'),
];
return $form;
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
$number = trim((string) $form_state->getValue('accession_number'));
if (!preg_match('/^MM\.\d{4}\.\d{4}$/', $number)) {
$form_state->setErrorByName('accession_number', $this->t('Enter the accession number in the form MM.YYYY.NNNN.'));
}
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$number = trim((string) $form_state->getValue('accession_number'));
$form_state->setRedirect('entity.collection_object.canonical_by_accession', ['accession_number' => $number]);
}
}
Source: Introduction to Form API
Points worth noticing:
-
$form_state->getValue('accession_number')returns one submitted value;$form_state->getValues()returns all of them, including the values of buttons and hidden Form API fields unlesscleanValues()is called first. -
$form_state->setErrorByName()attaches the message to the named element, marks it with an error class, and preventssubmitForm()from running. Without an error the form proceeds. -
$form_state->setRedirect()takes a route name;setRedirectUrl()takes aUrlobject. A redirect is ignored while the form is being rebuilt (see Multi-step forms). -
The
actionselement type wraps the buttons so that themes style them as one group. -
Strings are wrapped in
$this->t(); values go in placeholders (@name,%name,:url), never concatenated. A placeholder starting with@is escaped,%is escaped and emphasised, and:is for URLs. See Translation in code. -
#required => TRUEproduces the "field is required" message on the server and arequiredattribute in the markup. Required checking runs beforevalidateForm(), so it never sees an empty required field.
Form elements
Every key beginning with # is a property of the element that contains it; every other key is a child element. The
#type property selects an element plugin from the Drupal\Core\Render\Element namespace. The official documentation says
that there is no complete list of core elements and points to that namespace and to the Form and render element reference
on api.drupal.org.
The elements the Meridian forms use most:
#type |
Use |
|---|---|
|
Plain input. Use |
|
HTML5 elements. |
|
Choices from |
|
Selects an entity of |
|
Grouping. |
|
File upload with an ID returned in the value. Set |
|
Tabular layout and selectable rows. |
|
|
|
Display-only elements. |
|
Specialised elements used on administration screens. |
Source: Form Render Elements
Properties shared by most elements: #title, #title_display, #description, #default_value, #required, #disabled,
#access, #weight, #attributes, #prefix, #suffix, #states, #ajax, #element_validate, #value_callback,
#process, #after_build, #tree and #parents.
Two behaviours explain many surprises:
-
#treecontrols how values are nested. With#tree => FALSE(the default for the top level) a child of adetailselement is submitted as a flat key; with#tree => TRUEon a parent, its children are submitted under the parent’s key (['contact', 'email']) and are read with an array path:$form_state->getValue(['contact', 'email']). A#parentsproperty overrides the computed path. -
#disabled => TRUEand#access => FALSEboth stop user input from changing the stored default; Drupal ignores the submitted value for such an element and keeps#default_value(or#value). Do not rely on a hidden or disabled element to carry data that a user must not tamper with; compute it again in the submit handler.
Conditional fields with #states
#states shows, hides, enables, disables, requires or collapses an element depending on other fields, using JavaScript
that ships with Drupal’s core/drupal.states library. The states an element can take are visible/invisible,
enabled/disabled, required/optional, checked/unchecked and expanded/collapsed. The conditions are
empty/filled, checked/unchecked, expanded/collapsed and value. Each condition names its target field with a
jQuery selector, normally :input[name="field_name"] (the :input is a jQuery pseudo-selector, not a Drupal element
type). Conditions can be negated with a leading ! in the key, and combined with the strings and, or and xor.
$form['quote_type'] = [
'#type' => 'radios',
'#title' => $this->t('What is your enquiry about?'),
'#options' => [
'object' => $this->t('A collection object'),
'visit' => $this->t('A group visit'),
'other' => $this->t('Something else'),
],
'#default_value' => 'object',
];
$form['group_size'] = [
'#type' => 'number',
'#title' => $this->t('Number of visitors'),
'#min' => 10,
'#states' => [
'visible' => [
':input[name="quote_type"]' => ['value' => 'visit'],
],
'required' => [
':input[name="quote_type"]' => ['value' => 'visit'],
],
],
];
$form['details_other'] = [
'#type' => 'textarea',
'#title' => $this->t('Tell us more'),
'#states' => [
'invisible' => [
':input[name="quote_type"]' => ['value' => 'object'],
],
],
];
Source: Conditional Form Fields
What #states does not do:
-
It runs only in the browser and changes only presentation. A hidden field is still submitted, still validated and still saved. A visitor without JavaScript sees every field. The
requiredstate shows an asterisk but does not make the server reject an empty value. The reverse also holds: a field with#required => TRUEthat is hidden by#statesstill fails server-side validation when it is empty, and the visitor cannot see why. Make the field optional in PHP and enforce the rule invalidateForm(), as the enquiry form below does. -
A wrong selector fails silently. Inspect the real
nameattribute in the browser’s developer tools (the names of nested elements follow the#parentsarray and look likecontact[email]). -
requiredis applied to the input element, not to its wrapper; for aradiosorcheckboxesgroup apply it to the inner input. Checkbox fields on entity forms are nested inside a container, so target the innercheckboxelement. -
If another script sets a field’s value, trigger a user-like event so that the states code notices the change.
The Meridian settings form
The settings page of meridian_collection controls the synchronisation with the museum’s (fictitious) collections
management API. It stores a switch, the base URL of the API, the name of the environment variable that holds the API
token (the token itself never enters configuration, so it never reaches the exported YAML files described in
Configuration management), the batch size, a cron interval and the e-mail
addresses of the curators who are told when a sync fails.
Schema and default configuration
A configuration form is only as safe as the schema behind it. The schema declares the type of every key, and since Drupal 10.2 it can also declare validation constraints that the form applies when it is submitted:
# config/schema/meridian_collection.schema.yml
meridian_collection.settings:
type: config_object
label: 'Meridian collection settings'
mapping:
sync_enabled:
type: boolean
label: 'Synchronise collection objects'
api_base_url:
type: uri
label: 'Collections API base URL'
constraints:
NotBlank: {}
token_env_var:
type: string
label: 'Environment variable that holds the API token'
constraints:
Regex:
pattern: '/^[A-Z][A-Z0-9_]{2,63}$/'
message: 'Use an upper-case environment variable name such as MERIDIAN_API_TOKEN.'
batch_size:
type: integer
label: 'Objects per queue item'
constraints:
Range:
min: 1
max: 500
sync_interval:
type: string
label: 'How often to synchronise'
constraints:
Choice:
choices: [hourly, daily, weekly]
curator_emails:
type: sequence
label: 'Curators notified of a failed sync'
sequence:
type: email
label: 'E-mail address'
# config/install/meridian_collection.settings.yml
sync_enabled: false
api_base_url: 'https://collections.example.test/api/v2'
token_env_var: 'MERIDIAN_API_TOKEN'
batch_size: 50
sync_interval: daily
curator_emails: []
See Configuration API and schema for the schema types and the
constraint plugins in core. The default configuration file lives in config/install and is imported when the module is
installed; the example URL uses the reserved .test domain so that a copy of this file cannot reach a real service.
The form class
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\ConfigTarget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\RedundantEditableConfigNamesTrait;
use Drupal\meridian_collection\CollectionApiClientInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Settings for the synchronisation with the collections management API.
*/
class SettingsForm extends ConfigFormBase {
// Every mapped element uses #config_target, so the editable config names
// are derived from the form and getEditableConfigNames() is not needed.
use RedundantEditableConfigNamesTrait;
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typedConfigManager,
#[Autowire(service: 'meridian_collection.api_client')]
protected readonly CollectionApiClientInterface $apiClient,
) {
parent::__construct($config_factory, $typedConfigManager);
}
public function getFormId(): string {
return 'meridian_collection_settings';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['sync_enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Synchronise collection objects from the collections API'),
'#config_target' => 'meridian_collection.settings:sync_enabled',
];
$form['connection'] = [
'#type' => 'details',
'#title' => $this->t('Connection'),
'#open' => TRUE,
'#states' => [
'visible' => [':input[name="sync_enabled"]' => ['checked' => TRUE]],
],
];
$form['connection']['api_base_url'] = [
'#type' => 'url',
'#title' => $this->t('API base URL'),
'#maxlength' => 255,
'#config_target' => 'meridian_collection.settings:api_base_url',
];
$form['connection']['token_env_var'] = [
'#type' => 'textfield',
'#title' => $this->t('Token environment variable'),
'#description' => $this->t('Name of the server environment variable that holds the API token. The token itself is never stored in configuration.'),
'#config_target' => 'meridian_collection.settings:token_env_var',
];
$form['connection']['test_connection'] = [
'#type' => 'button',
'#value' => $this->t('Test connection'),
'#limit_validation_errors' => [['api_base_url']],
'#ajax' => [
'callback' => '::ajaxTestConnection',
'wrapper' => 'meridian-test-result',
'progress' => ['type' => 'throbber', 'message' => $this->t('Contacting the API...')],
],
];
$form['connection']['test_result'] = [
'#type' => 'container',
'#attributes' => ['id' => 'meridian-test-result'],
];
$form['sync'] = [
'#type' => 'details',
'#title' => $this->t('Schedule'),
'#open' => TRUE,
];
$form['sync']['sync_interval'] = [
'#type' => 'select',
'#title' => $this->t('Run the synchronisation'),
'#options' => [
'hourly' => $this->t('Every hour'),
'daily' => $this->t('Every day'),
'weekly' => $this->t('Every week'),
],
'#config_target' => 'meridian_collection.settings:sync_interval',
];
$form['sync']['batch_size'] = [
'#type' => 'number',
'#title' => $this->t('Objects per queue item'),
'#min' => 1,
'#max' => 500,
'#config_target' => 'meridian_collection.settings:batch_size',
];
$form['sync']['curator_emails'] = [
'#type' => 'textarea',
'#title' => $this->t('Curators to notify'),
'#description' => $this->t('One e-mail address per line.'),
'#rows' => 3,
'#config_target' => new ConfigTarget(
'meridian_collection.settings',
'curator_emails',
static::class . '::emailsToText',
static::class . '::textToEmails',
),
];
return parent::buildForm($form, $form_state);
}
public static function emailsToText(array $emails): string {
return implode("\n", $emails);
}
public static function textToEmails(string $text): array {
$lines = array_map('trim', preg_split('/\R/', $text) ?: []);
return array_values(array_filter($lines, static fn (string $line): bool => $line !== ''));
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
// Schema constraints are checked here by the parent implementation.
parent::validateForm($form, $form_state);
if ($form_state->getValue('sync_enabled')) {
$variable = (string) $form_state->getValue('token_env_var');
if ($variable !== '' && getenv($variable) === FALSE) {
$form_state->setErrorByName('token_env_var', $this->t('The environment variable %name is not set on this server.', ['%name' => $variable]));
}
}
}
public function ajaxTestConnection(array &$form, FormStateInterface $form_state): array {
$reachable = $this->apiClient->ping((string) $form_state->getValue('api_base_url'));
$form['connection']['test_result']['message'] = [
'#plain_text' => $reachable
? $this->t('The collections API answered.')
: $this->t('The collections API did not answer.'),
];
return $form['connection']['test_result'];
}
}
Source: Dependency Injection in a form
What the class relies on:
-
ConfigFormBase::buildForm()adds the "Save configuration" button, wraps the form in thesystem_config_formtheme hook and registers the#processcallback that loads default values from configuration. The form class must therefore callparent::buildForm($form, $form_state)and, as the change record points out, normally as the last step. Forgetting the call is the most common reason for#config_targetappearing to do nothing. -
The second half of the work happens in
ConfigFormBase::validateForm()andsubmitForm().validateForm()validates each mapped configuration object against its typed schema and attaches the constraint violations to the matching form elements;submitForm()copies the submitted values into the editable configuration objects, saves them and shows the "The configuration options have been saved." message. Both must be called from an overriding method. -
The configuration object is read with the
configfactory in the parent; a subclass that needs the values for other purposes reads$this->config('meridian_collection.settings'), which is immutable. Only the parent’ssubmitForm()obtains the editable object. -
The token comes from
getenv()at validation time and is read again by the API client when it runs; nothing about it is written to configuration, to the form state or to the logs.
#config_target in detail
#config_target appeared in Drupal 10.2. An element gets either a string of the form config.name:property.path or a
ConfigTarget object. Given the target, the form system
-
loads the element’s
#default_valuefrom the configuration property when the form is built, -
writes the submitted value back to that property when the form is submitted, and
-
validates the new value against the schema constraints of that property, reporting violations on the element.
The practical consequences:
-
Validation no longer lives in
validateForm(). The same constraints apply to every other way of changing the configuration (Drush, the Configuration synchronisation screen, recipes), anddrush config:inspectcan validate stored configuration without any form. -
The
ConfigTargetconstructor takes the configuration object name, a property path (or several paths), an optionalfromConfigcallable (configuration value to form value) and an optionaltoConfigcallable (submitted value to configuration value). Both callables are required when more than one property path is targeted. AtoConfigcallable receives the submitted value and theFormStateInterfaceand may returnToConfig::NoOp(leave the property alone),ToConfig::DeleteKey(remove the key) or any other value to store.ToConfiglives in theDrupal\Core\Formnamespace. -
When every mapped element uses
#config_target, the form can useRedundantEditableConfigNamesTraitand drop itsgetEditableConfigNames()method. A form that mixes#config_targetwith manual$config->set()calls keeps the method, which returns the names of the editable configuration objects. -
Callbacks written as closures cannot be serialised, which matters for forms that are cached between requests: AJAX forms and multi-step forms. The change record therefore recommends static methods over closures when a form uses AJAX, as the settings form does with
emailsToText()andtextToEmails(). The "Test connection" button above is safe for the same reason: it holds only class and method names. -
Adoption is optional. A form written with explicit
$config->set()calls insubmitForm()still works; new forms should use#config_target.
An older style, still common in contributed modules, reads the configuration in buildForm() and writes it in
submitForm():
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('meridian_collection.settings');
$form['batch_size'] = [
'#type' => 'number',
'#title' => $this->t('Objects per queue item'),
'#default_value' => $config->get('batch_size'),
];
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->config('meridian_collection.settings')
->set('batch_size', (int) $form_state->getValue('batch_size'))
->save();
parent::submitForm($form, $form_state);
}
protected function getEditableConfigNames(): array {
return ['meridian_collection.settings'];
}
This pattern casts and validates by hand, which is exactly what the schema constraints now do for the form. The constructor
change of 10.2 is the one practical trap: the subclass constructor must pass the typed config manager to the parent
(parent::__construct($config_factory, $typedConfigManager)), and a subclass that declares its own
$typedConfigManager property clashes with the parent’s promoted property. Both problems show up as fatal errors when a
module written for Drupal 10.0 is first run on Drupal 11.
Why configuration forms must not store secrets
A settings form is the easiest place to put an API token, and the worst: the value is exported to config/sync, committed
to Git and copied between environments. The Meridian form stores the name of an environment variable and reads the value
at run time, which keeps the secret in the server’s environment. Where a site needs an administrator to manage keys in the
UI, use a key-management module; the AI module of Drupal in AI module and
providers follows that approach. See Security hardening.
Confirmation forms with ConfirmFormBase
ConfirmFormBase implements the "Are you sure?" page used before a destructive action. It extends FormBase and
implements ConfirmFormInterface. At minimum, a subclass implements getFormId(), getQuestion(), getCancelUrl() and
submitForm(); getDescription() and getConfirmText() are optional overrides, and the base class supplies the
"Confirm" and "Cancel" buttons.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
/**
* Asks for confirmation before the synchronisation cursor is reset.
*/
class SyncResetConfirmForm extends ConfirmFormBase {
public function __construct(protected readonly StateInterface $state) {}
public function getFormId(): string {
return 'meridian_collection_sync_reset_confirm';
}
public function getQuestion(): TranslatableMarkup {
return $this->t('Reset the collection synchronisation?');
}
public function getDescription(): TranslatableMarkup {
return $this->t('The next run imports every object again. This can take several hours for a large collection.');
}
public function getConfirmText(): TranslatableMarkup {
return $this->t('Reset');
}
public function getCancelUrl(): Url {
return new Url('meridian_collection.settings');
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->state->delete('meridian_collection.sync_cursor');
$this->messenger()->addStatus($this->t('The synchronisation cursor was reset.'));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}
The route parameters of a confirmation form (an item ID, for instance) arrive as extra arguments of buildForm() and are
user-supplied: restrict them with a regular expression in the route requirements and check that the item exists and that
the current user may act on it. The confirmation page is itself a form, so it carries the token of
CSRF protection; this is what makes GET-style "delete" links unnecessary.
Dependency injection in forms
A form class is a service-like object that Drupal creates for every request; it should receive the services it uses through its constructor. Three approaches are in use:
-
Explicit wiring: a static
create(ContainerInterface $container)method that calls$container->get('service.id')for each argument and returnsnew static(...). It works on every version and gives full control of the service IDs. -
Autowiring:
FormBaseusesAutowireTrait, so on a current Drupal a form class just declares the typed constructor parameters and Drupal resolves them from the container. The settings form above relies on this forConfigFactoryInterfaceandTypedConfigManagerInterface, which core publishes as autowiring aliases. -
#[Autowire(service: '...')]on a constructor parameter, for the cases where several services share one interface (the cache backends are the usual example) or where the interface has no alias. The settings form uses it for its own API client.
Rules to remember:
-
Declare injected properties
protectedorpublic, neverprivate. Forms that use#ajaxor are cached between requests are serialised withDependencySerializationTrait, and a private service property is lost when the form is unserialised in a later AJAX request. The result is an error such as "call to a member function on null" that appears only on the second request. -
A constructor argument that is not a service (an entity storage, a plain value) still needs an explicit
create(). -
\Drupal::static calls and the accessors inherited fromFormBase(currentUser(),getRouteMatch()) are for code that cannot be injected, such as procedural hooks and tests. A new class injects. -
The injected services should be typed with interfaces so that a test can substitute a fake one.
Source: Dependency Injection in a form
See Services and dependency injection for the container itself.
Validation
Validation runs in this order for a submitted form:
-
Required-field and element checks: each element’s
#required,#maxlength,#optionsmembership (aselect,radiosorcheckboxesvalue must be one of the declared options) and the built-in rules ofemail,url,numberand similar types. -
#element_validatecallbacks attached to individual elements, which receive the element, the form state and the complete form. -
#validatecallbacks andvalidateForm()for rules that involve several fields.
Rules of thumb:
-
Report problems with
setErrorByName($name, $message). For a nested element the name is the parents path joined with][, for examplecontact][email;$form_state->setError($element, $message)takes the element itself and is simpler inside an#element_validatecallback. -
Validate on the server even when the browser,
#statesor a JavaScript widget already checks. Nothing from the browser is trusted. -
Keep validation free of side effects. Validation can run several times for the same submission (an AJAX request, a rebuild), and changing data there will repeat the change. Do the work in
submitForm(). -
Use
#limit_validation_errorson a button to validate only the sections relevant to that button. The list contains parent paths ([['api_base_url']]); an empty array skips validation for that button. Values outside the listed sections are removed from the form state, so a handler that needs them must not use a limited button. -
Do not use
$form_state->getUserInput()for decisions. It is the raw, unvalidated request;getValue()is the processed, validated value.
public function validateForm(array &$form, FormStateInterface $form_state): void {
$type = $form_state->getValue('quote_type');
$size = (int) $form_state->getValue('group_size');
if ($type === 'visit' && $size < 10) {
$form_state->setErrorByName('group_size', $this->t('A group visit needs at least 10 visitors.'));
}
}
Source: Form API Workflow
Submission
A submit handler runs only after validation has passed. It receives the finished, validated values and does the work:
-
store data with the services the form received (entity storage, configuration, the state service, the queue);
-
tell the user with
$this->messenger()->addStatus(),addWarning()oraddError(); -
choose the next page with
$form_state->setRedirect()orsetRedirectUrl(), or call$form_state->setRebuild()to show the same form again.
Submit handlers should read values by name, not copy every value. A handler that does
$entity->set($key, $value) for each entry of $form_state->getValues() lets a visitor add fields the form never
offered, because the request can contain extra keys that the handler would then accept. If a loop is unavoidable, restrict it
to a list of allowed keys, and call $form_state->cleanValues() first to drop the Form API’s own keys (form_id,
form_token, op and the buttons).
Long-running work does not belong in a submit handler. Hand it to a queue (see
Queue, batch and cron APIs) and report that it was queued. The Batch API
(batch_set() and a batch builder) shows progress for jobs that need to run in the foreground.
AJAX forms with #ajax
The AJAX system re-requests the form, runs the triggering element’s callback and returns commands to the browser. The dedicated page AJAX and HTMX covers the framework, the commands and the JavaScript side; this section covers what a form author needs.
An element becomes an AJAX trigger by getting an #ajax array. All keys are optional:
| Key | Meaning |
|---|---|
|
The function that builds the response. Use |
|
The HTML |
|
How the new content is placed: |
|
|
|
The DOM event, without the "on" prefix. The default depends on the element; submit buttons default to |
|
The indicator: |
|
Stops the automatic refocus after the request. |
Source: AJAX Forms
A callback may return a render array (usually the updated form element, which replaces the wrapper’s contents), a string of
markup (wrapped in #markup; it must include the wrapper id) or an AjaxResponse carrying commands such as
ReplaceCommand, OpenModalDialogCommand (which needs the core/drupal.dialog.ajax library) and InvokeCommand. Returning
NULL or an invalid render array breaks the form. Selectors in command classes need the # prefix, unlike the wrapper key.
A dependent select box with #ajax
On the Meridian "Find an object" form the visitor chooses a gallery and the room list changes to match. Each AJAX request
rebuilds the form, so buildForm() reads the value of the triggering field and builds the dependent field from it; the
callback only returns the part of the rebuilt form that has to be replaced.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Chooses a gallery, then one of its rooms.
*/
class GalleryFinderForm extends FormBase {
public function getFormId(): string {
return 'meridian_collection_gallery_finder';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$gallery = $form_state->getValue('gallery');
$form['gallery'] = [
'#type' => 'select',
'#title' => $this->t('Gallery'),
'#options' => $this->galleries(),
'#empty_option' => $this->t('- Choose a gallery -'),
'#ajax' => [
'callback' => '::updateRooms',
'wrapper' => 'meridian-rooms',
'event' => 'change',
],
];
$form['rooms'] = [
'#type' => 'container',
'#attributes' => ['id' => 'meridian-rooms'],
];
$form['rooms']['room'] = [
'#type' => 'select',
'#title' => $this->t('Room'),
'#options' => $gallery ? $this->rooms((string) $gallery) : [],
'#empty_option' => $this->t('- Choose a room -'),
'#disabled' => !$gallery,
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Show objects on display'),
];
return $form;
}
public function updateRooms(array &$form, FormStateInterface $form_state): array {
return $form['rooms'];
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$form_state->setRedirect('view.on_display.page_1', [], [
'query' => [
'gallery' => $form_state->getValue('gallery'),
'room' => $form_state->getValue('room'),
],
]);
}
/**
* @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
*/
protected function galleries(): array {
return [
'space' => $this->t('Space and astronomy'),
'earth' => $this->t('Earth sciences'),
];
}
/**
* @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
*/
protected function rooms(string $gallery): array {
return match ($gallery) {
'space' => ['dome' => $this->t('Planetarium dome'), 'orrery' => $this->t('Orrery hall')],
'earth' => ['rocks' => $this->t('Rock and mineral room'), 'quake' => $this->t('Earthquake lab')],
default => [],
};
}
}
Source: AJAX Forms
Common AJAX mistakes:
-
the callback returns an element without the wrapper
id, so the second change event finds nothing to replace; -
a dependent select keeps its previous value because the value is not reset when the parent changes (clear it in
buildForm()by checking$form_state->getTriggeringElement()); -
a private service property in the form class is lost between requests (see Dependency injection in forms);
-
a closure stored in the form array or in
#config_targetcannot be serialised; -
the form is large and slow, because every AJAX request rebuilds all of it.
If the form fails with "The form has become outdated", the form-state cache entry or the token no longer matches. Typical causes are a form cached by a proxy that ignored the vary rules, a cache clear while a visitor was filling in the form, or a session that changed between rendering and submitting.
#ajax versus HTMX
Drupal 11.2 added the htmx library as a dependency and Drupal 11.3 turned it into a supported part of the Ajax subsystem.
HTMX works on declarative HTML attributes: an element states which request to make (hx-post), which part of the response
to use (hx-select), where to put it (hx-target) and how (hx-swap). Drupal supports the standard data-hx- prefix
as well, and offers the \Drupal\Core\Htmx\Htmx class, which builds those attributes and the matching response headers.
-
The
Htmxclass has methods for every HTMX attribute and header and ends withapplyTo($element), which writes the attributes into a render array (and merges the cacheability of anyUrlthat was passed in). It is internal by policy, has no interface and changes only when HTMX changes.Htmx::createFromRenderArray()builds an instance from an existing element. -
FormBuilderrecognises HTMX requests with the same awareness it has for Ajax API requests, and adds HTMX attributes to theform_build_idelement so that its value follows the response. -
FormBaseusesHtmxRequestInfoTrait(11.3), whosegetHtmxTriggerName()helper replaces reading theHX-Trigger-Namerequest header by hand. -
onlyMainContent()requests thedrupal_htmxwrapper format, which returns only the main content and the CSS and JavaScript it needs in a minimal HTML document. -
A route that serves HTMX requests may set the
_htmx_route: TRUEoption. -
The HTMX library is attached through the
core/drupal.htmxlibrary. -
Proxies, CDNs and WAFs must pass the HTMX request headers (
HX-Request,HX-Boosted,HX-Current-URL,HX-History-Restore-Request,HX-Prompt,HX-Target,HX-Trigger,HX-Trigger-Name) and the response headers such asHX-Redirect. Hosts that filter non-standard headers break HTMX forms without any error on the server.
The same dependent select box with HTMX
With HTMX the callback disappears. The select box carries the request attributes, the server rebuilds the form with the
submitted value of gallery (the whole form is available to the form class), and HTMX replaces the matching container in
the response. Because the entire container is swapped, validation errors inside it appear too.
use Drupal\Core\Htmx\Htmx;
public function buildForm(array $form, FormStateInterface $form_state): array {
$gallery = $form_state->getValue('gallery');
$form['gallery'] = [
'#type' => 'select',
'#title' => $this->t('Gallery'),
'#options' => $this->galleries(),
'#empty_option' => $this->t('- Choose a gallery -'),
];
(new Htmx())
->post()
->onlyMainContent()
->select('#meridian-rooms')
->target('#meridian-rooms')
->swap('outerHTML')
->applyTo($form['gallery']);
$form['rooms'] = [
'#type' => 'container',
'#attributes' => ['id' => 'meridian-rooms'],
];
$form['rooms']['room'] = [
'#type' => 'select',
'#title' => $this->t('Room'),
'#options' => $gallery ? $this->rooms((string) $gallery) : [],
'#empty_option' => $this->t('- Choose a room -'),
'#disabled' => !$gallery,
];
// The submit button and submitForm() are unchanged.
return $form;
}
Source: Native HTMX in Drupal 11.3.0
post() without an argument posts to the current URL, which is what a form usually wants.
Which one to use
| Question | #ajax |
HTMX (11.3 and later) |
|---|---|---|
Where is the behaviour described? |
In the form array ( |
In attributes generated by the |
Response |
Commands ( |
HTML that the browser swaps in; headers (for example |
Client code |
Drupal’s jQuery-based |
The htmx library; no jQuery needed. |
State |
The form-state cache keyed by |
The same form builder handles the rebuild; the |
Availability |
All Drupal 8 and later versions, and still the way that most contributed code works. |
Drupal 11.3 and later only. A module that must also support Drupal 10.x or 11.0 to 11.2 needs |
Future |
Maintained alongside HTMX; the community plan is to replace Drupal’s AJAX system with HTMX gradually, but the change
record does not announce the deprecation of |
Drupal 12 moves to htmx 4. |
The practical guidance for the Meridian module: use #states for pure show-and-hide logic, keep #ajax where the module
must run on Drupal 10.x or where existing callbacks and commands work, and write new interactions for Drupal 11.3 and later
with the Htmx class. Do not mix both on one element.
Drupal 12 and htmx 4
The change record for Drupal 12.0.0-beta1 moves core to htmx 4, a rewrite with breaking changes. Items that touch forms:
fetch() replaces XMLHttpRequest, error responses are swapped into the target, several attributes are removed
(hx-params, hx-prompt, hx-ext, hx-history), the HX-Trigger request header is replaced by HX-Source, and event
names change. In the Htmx class disable() becomes ignore() and disabledElt() takes over the name disable(); replace
the calls in that order. FormBuilderInterface::HTMX_REQUEST is deprecated for removal in Drupal 13. Because the changes
are not backwards compatible, a module that supports both Drupal 11 and 12 and uses these calls needs
DeprecationHelper. Check the record again at the final release.
Multi-step forms
A multi-step form is one form class that shows different elements at different times. Drupal does not remember the step;
the form keeps it in the form state storage, asks for a rebuild after each step and builds the elements of the current
step in buildForm().
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Two-step enquiry about a collection object.
*/
class ObjectEnquiryForm extends FormBase {
public function getFormId(): string {
return 'meridian_collection_object_enquiry';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$step = (int) ($form_state->get('step') ?? 1);
$form['#prefix'] = '<div id="meridian-enquiry">';
$form['#suffix'] = '</div>';
$form['progress'] = [
'#markup' => $this->t('Step @step of 2', ['@step' => $step]),
];
if ($step === 1) {
$form['object'] = [
'#type' => 'entity_autocomplete',
'#target_type' => 'collection_object',
'#title' => $this->t('Which object?'),
'#required' => TRUE,
'#default_value' => $form_state->get('object') ? $this->loadObject($form_state->get('object')) : NULL,
];
}
else {
$form['name'] = [
'#type' => 'textfield',
'#title' => $this->t('Your name'),
'#required' => TRUE,
'#maxlength' => 100,
];
$form['email'] = [
'#type' => 'email',
'#title' => $this->t('Your e-mail address'),
'#required' => TRUE,
];
$form['message'] = [
'#type' => 'textarea',
'#title' => $this->t('Your question'),
'#required' => TRUE,
'#rows' => 5,
];
}
$form['actions'] = ['#type' => 'actions'];
if ($step === 2) {
$form['actions']['back'] = [
'#type' => 'submit',
'#value' => $this->t('Back'),
'#submit' => ['::goBack'],
'#limit_validation_errors' => [],
];
}
$form['actions']['next'] = [
'#type' => 'submit',
'#value' => $step === 1 ? $this->t('Next') : $this->t('Send enquiry'),
'#submit' => $step === 1 ? ['::goNext'] : ['::submitForm'],
];
return $form;
}
public function goNext(array &$form, FormStateInterface $form_state): void {
$form_state
->set('object', $form_state->getValue('object'))
->set('step', 2)
->setRebuild(TRUE);
}
public function goBack(array &$form, FormStateInterface $form_state): void {
$form_state->set('step', 1)->setRebuild(TRUE);
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
// Log the object, never the visitor's name, address or message.
$this->logger('meridian_collection')->info('Enquiry received about object @id.', [
'@id' => $form_state->get('object'),
]);
$this->messenger()->addStatus($this->t('Thank you. A curator will answer by e-mail.'));
$form_state->setRedirect('<front>');
}
protected function loadObject(mixed $id): mixed {
return \Drupal::entityTypeManager()->getStorage('collection_object')->load($id);
}
}
Source: Form API Workflow
Notes on this pattern:
-
$form_state->set('step', 2)and$form_state->get('step')read and write the form-state storage;setRebuild(TRUE)tells Drupal to runbuildForm()again with the same state instead of finishing. While a rebuild is pending, redirects set in a handler are ignored. -
The storage lives in the form cache (keyed by
form_build_id) for a limited time, so it must contain only serialisable values: scalars, arrays, entity IDs. Do not keep entities, closures or service objects there. The methodloadObject()above reaches for\Drupal::entityTypeManager()for brevity; in the real module it takes an injectedEntityTypeManagerInterfacelike every other class. -
The Back button uses
#limit_validation_errors => []so that the visitor can go back with incomplete fields. -
A wizard that spans several requests and must survive a browser restart needs a persistent store (the private temp store or a table) rather than form state.
-
A multi-step form can be combined with
#ajaxon the buttons so that each step replaces the wrapper without a page load. The change record for#config_targetnotes that closures in config targets limit complex AJAX handling such as multi-step forms, so mixed forms use static methods. -
Validation of step 1 happens before the step changes, because the Next button runs the standard validation first; a failed validation keeps the visitor on the same step with the error messages.
Anonymous visitors can submit this form, and anonymous forms are a spam target. The token that protects authenticated forms is not added for anonymous users (see CSRF protection); combine the form with a honeypot or CAPTCHA module and flood control, as described in Forms for visitors: Contact and Webform.
Altering forms
Any module can change a form that another module (or core) built. Drupal calls, in this order:
-
hook_form_alter(&$form, FormStateInterface $form_state, $form_id), for every form; -
hook_form_BASE_FORM_ID_alter(), for forms that share a base form ID. Content entity edit forms use the base IDnode_form, so one hook alters the create and edit forms of every node type; -
hook_form_FORM_ID_alter(), for one specific form such asnode_exhibition_form.
Since Drupal 11.1 a module implements these as methods with the #[Hook] attribute on a class in
src/Hook. Drupal registers the class as an autowired service automatically, so constructor injection works; no
.module function and no services.yml entry are needed:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Form alterations for the Meridian museum site.
*/
class FormHooks {
use StringTranslationTrait;
/**
* Tidies the exhibition edit forms for content editors.
*/
#[Hook('form_node_form_alter')]
public function nodeFormAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
$entity = $form_state->getFormObject()->getEntity();
if ($entity->bundle() !== 'exhibition') {
return;
}
if (isset($form['title']['widget'][0]['value'])) {
$form['title']['widget'][0]['value']['#maxlength'] = 80;
$form['title']['widget'][0]['value']['#description'] = $this->t('Keep it short: the title is used on the home page teaser.');
}
$form['collection_links'] = [
'#type' => 'details',
'#title' => $this->t('Collection links'),
'#open' => FALSE,
'#weight' => 40,
];
foreach (['field_gallery', 'field_related_objects'] as $name) {
if (isset($form[$name])) {
$form['collection_links'][$name] = $form[$name];
unset($form[$name]);
}
}
}
}
Facts to know:
-
The same method can carry several
#[Hook]attributes, and one module can implement most hooks more than once. -
The hook name omits the
hook_prefix:#[Hook('form_alter')],#[Hook('form_node_form_alter')],#[Hook('form_node_exhibition_form_alter')]. -
Drupal 11.2 added ordering, removal and replacement attributes, which replace
hook_module_implements_alter()and let one module run before or after another when both alter the same form. See Hooks and OOP hooks. -
Procedural hook functions in the
.modulefile still run on Drupal 11 and are expected to be removed no earlier than Drupal 13. A module that still supports Drupal 10.x keeps procedural shims marked#[LegacyHook]that delegate to the class. Themes can implement hooks as classes from 11.3. -
Moving elements in an alter hook is fragile:
$formis whatever the original builder produced, and entity forms restructure their arrays between versions. Test for the presence of keys, as the example does, and prefer form modes and field widgets (see Display modes and image styles) when the aim is only to reorder fields. -
To attach extra validation or submit handling to someone else’s form, append to
$form['#validate'][]or$form['actions']['submit']['#submit'][]. Use static or::callbacks, not closures, so the form can be cached.
CSRF protection and form security
The Form API protects forms by default; the developer’s job is mostly not to switch the protection off.
-
Form token. For authenticated users, Drupal adds a hidden
form_tokenfield to each form that uses the POST method, derived from the session and the form ID, and checks it on submission. A request forged from another site cannot include it. The token is rendered lazily, through a#lazy_builder, so that the rest of the form can be cached; a form is uncacheable unless it opts in with its own cache metadata (this change shipped in the 11.1.0 release). Anonymous visitors get no token, because generating one would need a session for each of them and would defeat the page cache. -
form_build_idandform_id. The first identifies the cached form state; the second names the form class. A submission whose identifiers do not match a known form fails with an "outdated form" message. -
#token => FALSEremoves the token. Core does it for search-style forms that only read data. Never set it on a form that changes state. A GET form ($form_state->setMethod('GET')) receives no token either and must be free of side effects. -
Route-level tokens. Routes that perform an action through a link rather than a form use the
_csrf_token: 'TRUE'requirement. The logout route/user/logouthas been CSRF-protected since Drupal 10.3. See the CSRF access checking page. -
Access. The route requirement decides who may open the form;
#accessdecides who sees an element. Check permissions in the handler too when it acts on an entity that the route did not load. -
Output. Elements escape their
#title,#descriptionand option labels. Do not mark user-supplied strings as safe, do not put them in#markupwithout the filter, and use#plain_textwhen the value is plain text. Autoescaping in Twig stays on. -
Redirects. A
destinationquery parameter is accepted only when it points inside the site; do not build a redirect from an unchecked request value. -
File uploads. Set
#upload_validatorswith extensions and a size limit on everymanaged_fileelement, and store sensitive uploads in the private file system. -
Spam and abuse. Forms open to anonymous visitors need rate limiting and bot defences; see Forms for visitors: Contact and Webform and Security hardening.
Source: CSRF access checking
Testing forms
Form classes are tested at three levels. A kernel test builds the form through the form builder and submits it programmatically, which exercises validation and the submit handler without a browser:
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\Core\Form\FormState;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\Form\AccessionLookupForm;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
class AccessionLookupFormTest extends KernelTestBase {
protected static $modules = ['system', 'user', 'meridian_collection'];
public function testRejectsMalformedAccessionNumber(): void {
$form_state = (new FormState())->setValues(['accession_number' => 'not-a-number']);
$this->container->get('form_builder')->submitForm(AccessionLookupForm::class, $form_state);
$this->assertTrue($form_state->hasAnyErrors());
$this->assertArrayHasKey('accession_number', $form_state->getErrors());
}
}
A functional test (BrowserTestBase) logs in a user, requests the route and uses submitForm() on the page to check the
rendered messages and the saved configuration, and a FunctionalJavascript test checks #states and AJAX behaviour. See
Automated testing for the test types, the attributes that replace annotations
since Drupal 11.2 and the commands that run them.
Common errors and pitfalls
-
Forgetting
parent::buildForm()in aConfigFormBaseform. The Save button, theme hook and default-value callback are missing, and#config_targetseems to do nothing. Call it, normally last. Do the same forparent::validateForm()andparent::submitForm(). -
Constructor mismatch after upgrading.
ConfigFormBase::__construct()takes$config_factoryand$typedConfigManager; code that passes one argument fails on Drupal 11. -
A non-unique or colliding form ID. The ID must be unique across the site and must not equal a theme name.
-
#requiredwith#states. A required field hidden by JavaScript still blocks the submission on the server. -
#limit_validation_errorsdropping values. A button with a narrow list loses every value outside it. -
Private service properties and closures in AJAX forms. They break when the form is unserialised on the next request.
-
Wrong AJAX wrapper. The
wrapperid is missing from the markup that is returned, or it is not unique. -
Side effects in
validateForm()orbuildForm(). Both run several times per user action. Keep them free of writes. -
Looping over
getValues(). It copies unexpected keys into entities and configuration. -
Secrets in configuration forms. Store the name of an environment variable or use a key-management module.
-
Calling
\Drupal::in new classes. Inject the services; tests then use fakes. -
Raw HTML forms. They have no token, validation or alter hooks.
-
HTMX behind a proxy that strips headers. The form works locally and fails in production; pass the HX headers.
-
Using Drupal 7 idioms.
drupal_get_form(),variable_get()and a single.moduleform function are not how Drupal 8 and later work; use form classes, the Configuration API and routes.
Version notes and migration checklist
-
Replace hand-written copy-in, copy-out logic in
ConfigFormBaseforms with#config_target(Drupal 10.2 and later) and move the validation into the schema. -
Pass
TypedConfigManagerInterfaceto the parent constructor (required on Drupal 11). -
Move
create()boilerplate to autowiring; use#[Autowire]where the interface has several implementations. -
Convert
hook_form_alter()implementations to#[Hook]methods once the minimum core version is 11.1; keep#[LegacyHook]shims while Drupal 10.x is supported. -
Plan new dynamic behaviour with the
Htmxclass for 11.3 and later, and review calls toHtmx::disable(), theHX-Triggerheader andFormBuilderInterface::HTMX_REQUESTbefore Drupal 12. -
Check the infrastructure: proxies and CDNs must pass the HTMX headers from Drupal 11.3 on.
See Preparing for Drupal 12 and Upgrading from Drupal 10 to 11 for the wider upgrade picture, and Where older books and tutorials are outdated for the collected corrections.
References
-
Change record: New parameter added to ConfigFormBase::__construct
-
Change record: Support for object oriented hook implementations
-
Change record: New trait assists classes building render arrays for HTMX
-
Change record: HTMX requests may be configured to use the drupal_htmx wrapper format
-
Form tokens are now rendered lazily, allow forms to opt in to be cacheable