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 _form key.

  • Drupal 10.1 added autowiring aliases for core services.

  • Drupal 10.2 added the #config_target property to ConfigFormBase forms, the ConfigTarget helper class and constraint-based validation of the submitted configuration. The same release made the $typedConfigManager constructor argument of ConfigFormBase available (optional in 10.2, required from 11.0). Form classes can use AutowireTrait and #[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\Htmx class, FormBuilder awareness of HTMX requests, the HtmxRequestInfoTrait on FormBase, and the drupal_htmx wrapper format. #ajax keeps working; the form builder treats both kinds of request alike.

  • Drupal 12 moves to htmx 4. Request headers and several Htmx methods change (see HTMX in core), and FormBuilderInterface::HTMX_REQUEST is 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.

flowchart TD R[Request for a route with _form] --> B[getFormId and buildForm] B --> A[Alter hooks: hook_form_alter, BASE_FORM_ID, FORM_ID] A --> P[process and after_build callbacks] P --> M{Submitted?} M -- no --> H[Render form with form_build_id, form_id and form_token] M -- yes --> T{Token and form_id valid?} T -- no --> X[Form has become outdated, error] T -- yes --> V[Element validation, then validateForm] V --> E{Errors?} E -- yes --> H E -- no --> S[submitForm handlers] S --> W{Rebuild requested?} W -- yes --> B W -- no --> D[Redirect or response]

The main extension points, in the order they run:

Point Purpose

getFormId()

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.

buildForm()

Returns the form array. Anything that depends on the request, the route parameters or the stored state belongs here.

Alter hooks

hook_form_alter(), then hook_form_BASE_FORM_ID_alter(), then hook_form_FORM_ID_alter() change a form that someone else built.

#process, #after_build

Per-element callbacks that expand an element (for example a radios element into many radio children) or adjust the finished array.

#element_validate, #validate, validateForm()

Validation: first each element’s own callbacks, then the form-level callbacks.

#submit, submitForm()

Submission. A button can carry its own #submit list; otherwise the form-level handlers run.

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'

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]);
  }

}

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 unless cleanValues() is called first.

  • $form_state->setErrorByName() attaches the message to the named element, marks it with an error class, and prevents submitForm() from running. Without an error the form proceeds.

  • $form_state->setRedirect() takes a route name; setRedirectUrl() takes a Url object. A redirect is ignored while the form is being rebuilt (see Multi-step forms).

  • The actions element 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 => TRUE produces the "field is required" message on the server and a required attribute in the markup. Required checking runs before validateForm(), 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

textfield, textarea, password, hidden

Plain input. Use #maxlength, #rows and #size to limit and shape it.

email, tel, number, url, date, search, range

HTML5 elements. tel lets a phone show a numeric keypad. The browser’s own validation is a convenience only; Drupal validates email, url and number values again on the server.

select, radios, checkboxes, checkbox

Choices from #options. Use #empty_option and #empty_value on a select to add a "- Choose -" entry.

entity_autocomplete

Selects an entity of #target_type, optionally limited with #selection_settings. Its value is the entity ID (or an array for #tags).

details, fieldset, vertical_tabs, container

Grouping. details is a collapsible region with a summary; container is a plain wrapper that does not create an HTML fieldset and takes #attributes.

managed_file

File upload with an ID returned in the value. Set #upload_validators and #upload_location; see File system and private files.

table, tableselect

Tabular layout and selectable rows.

submit, button, actions

submit triggers validation and submission; button triggers validation only and is the usual choice for an #ajax trigger; actions wraps both.

item, markup, processed_text, link

Display-only elements. processed_text runs a text format; markup and #markup pass through the XSS filter.

language_select, dropbutton, operations

Specialised elements used on administration screens.

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:

  • #tree controls how values are nested. With #tree => FALSE (the default for the top level) a child of a details element is submitted as a flat key; with #tree => TRUE on 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 #parents property overrides the computed path.

  • #disabled => TRUE and #access => FALSE both 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'],
    ],
  ],
];

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 required state shows an asterisk but does not make the server reject an empty value. The reverse also holds: a field with #required => TRUE that is hidden by #states still fails server-side validation when it is empty, and the visitor cannot see why. Make the field optional in PHP and enforce the rule in validateForm(), as the enquiry form below does.

  • A wrong selector fails silently. Inspect the real name attribute in the browser’s developer tools (the names of nested elements follow the #parents array and look like contact[email]).

  • required is applied to the input element, not to its wrapper; for a radios or checkboxes group apply it to the inner input. Checkbox fields on entity forms are nested inside a container, so target the inner checkbox element.

  • 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'];
  }

}

What the class relies on:

  • ConfigFormBase::buildForm() adds the "Save configuration" button, wraps the form in the system_config_form theme hook and registers the #process callback that loads default values from configuration. The form class must therefore call parent::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_target appearing to do nothing.

  • The second half of the work happens in ConfigFormBase::validateForm() and submitForm(). 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 config factory in the parent; a subclass that needs the values for other purposes reads $this->config('meridian_collection.settings'), which is immutable. Only the parent’s submitForm() 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

  1. loads the element’s #default_value from the configuration property when the form is built,

  2. writes the submitted value back to that property when the form is submitted, and

  3. 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), and drush config:inspect can validate stored configuration without any form.

  • The ConfigTarget constructor takes the configuration object name, a property path (or several paths), an optional fromConfig callable (configuration value to form value) and an optional toConfig callable (submitted value to configuration value). Both callables are required when more than one property path is targeted. A toConfig callable receives the submitted value and the FormStateInterface and may return ToConfig::NoOp (leave the property alone), ToConfig::DeleteKey (remove the key) or any other value to store. ToConfig lives in the Drupal\Core\Form namespace.

  • When every mapped element uses #config_target, the form can use RedundantEditableConfigNamesTrait and drop its getEditableConfigNames() method. A form that mixes #config_target with 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() and textToEmails(). 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 in submitForm() 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:

  1. Explicit wiring: a static create(ContainerInterface $container) method that calls $container->get('service.id') for each argument and returns new static(...). It works on every version and gives full control of the service IDs.

  2. Autowiring: FormBase uses AutowireTrait, 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 for ConfigFactoryInterface and TypedConfigManagerInterface, which core publishes as autowiring aliases.

  3. #[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 protected or public, never private. Forms that use #ajax or are cached between requests are serialised with DependencySerializationTrait, 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 from FormBase (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.

See Services and dependency injection for the container itself.

Validation

Validation runs in this order for a submitted form:

  1. Required-field and element checks: each element’s #required, #maxlength, #options membership (a select, radios or checkboxes value must be one of the declared options) and the built-in rules of email, url, number and similar types.

  2. #element_validate callbacks attached to individual elements, which receive the element, the form state and the complete form.

  3. #validate callbacks and validateForm() 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 example contact][email; $form_state->setError($element, $message) takes the element itself and is simpler inside an #element_validate callback.

  • Validate on the server even when the browser, #states or 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_errors on 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.'));
  }
}

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() or addError();

  • choose the next page with $form_state->setRedirect() or setRedirectUrl(), 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

callback

The function that builds the response. Use '::methodName' for a method of the form class or a callable array; a bare string names a procedural function.

wrapper

The HTML id (no leading #) of the element to update. It must be unique on the page and must be present both in the rendered form and in the markup the callback returns.

method

How the new content is placed: replaceWith (default), append, prepend, before, after or html.

effect, speed

none (default), slide or fade, with slow, fast or a number of milliseconds.

event

The DOM event, without the "on" prefix. The default depends on the element; submit buttons default to mousedown, which is why prevent: click exists.

progress

The indicator: type (throbber, bar or none), message, and for a bar a url and an interval.

disable-refocus

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_target cannot 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 Htmx class has methods for every HTMX attribute and header and ends with applyTo($element), which writes the attributes into a render array (and merges the cacheability of any Url that 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.

  • FormBuilder recognises HTMX requests with the same awareness it has for Ajax API requests, and adds HTMX attributes to the form_build_id element so that its value follows the response.

  • FormBase uses HtmxRequestInfoTrait (11.3), whose getHtmxTriggerName() helper replaces reading the HX-Trigger-Name request header by hand.

  • onlyMainContent() requests the drupal_htmx wrapper 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: TRUE option.

  • The HTMX library is attached through the core/drupal.htmx library.

  • 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 as HX-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;
}

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 (callback, wrapper), with a PHP callback per trigger.

In attributes generated by the Htmx class; the form’s buildForm() decides what to render.

Response

Commands (AjaxResponse) or a render array.

HTML that the browser swaps in; headers (for example HX-Redirect) for navigation.

Client code

Drupal’s jQuery-based Drupal.Ajax.

The htmx library; no jQuery needed.

State

The form-state cache keyed by form_build_id.

The same form builder handles the rebuild; the form_build_id element is updated on the response.

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 #ajax.

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 #ajax.

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().

stateDiagram-v2 [*] --> Step1 Step1 --> Step2: Next, validated, step stored, rebuild Step2 --> Step1: Back, no validation, rebuild Step2 --> Step2: Validation error Step2 --> [*]: Send enquiry, submitForm
<?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);
  }

}

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 run buildForm() 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 method loadObject() above reaches for \Drupal::entityTypeManager() for brevity; in the real module it takes an injected EntityTypeManagerInterface like 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 #ajax on the buttons so that each step replaces the wrapper without a page load. The change record for #config_target notes 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:

  1. hook_form_alter(&$form, FormStateInterface $form_state, $form_id), for every form;

  2. hook_form_BASE_FORM_ID_alter(), for forms that share a base form ID. Content entity edit forms use the base ID node_form, so one hook alters the create and edit forms of every node type;

  3. hook_form_FORM_ID_alter(), for one specific form such as node_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 .module file 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: $form is 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_token field 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_id and form_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 => FALSE removes 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/logout has been CSRF-protected since Drupal 10.3. See the CSRF access checking page.

  • Access. The route requirement decides who may open the form; #access decides 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, #description and option labels. Do not mark user-supplied strings as safe, do not put them in #markup without the filter, and use #plain_text when the value is plain text. Autoescaping in Twig stays on.

  • Redirects. A destination query parameter is accepted only when it points inside the site; do not build a redirect from an unchecked request value.

  • File uploads. Set #upload_validators with extensions and a size limit on every managed_file element, 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.

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 a ConfigFormBase form. The Save button, theme hook and default-value callback are missing, and #config_target seems to do nothing. Call it, normally last. Do the same for parent::validateForm() and parent::submitForm().

  • Constructor mismatch after upgrading. ConfigFormBase::__construct() takes $config_factory and $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.

  • #required with #states. A required field hidden by JavaScript still blocks the submission on the server.

  • #limit_validation_errors dropping 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 wrapper id is missing from the markup that is returned, or it is not unique.

  • Side effects in validateForm() or buildForm(). 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 .module form function are not how Drupal 8 and later work; use form classes, the Configuration API and routes.

Version notes and migration checklist

  1. Replace hand-written copy-in, copy-out logic in ConfigFormBase forms with #config_target (Drupal 10.2 and later) and move the validation into the schema.

  2. Pass TypedConfigManagerInterface to the parent constructor (required on Drupal 11).

  3. Move create() boilerplate to autowiring; use #[Autowire] where the interface has several implementations.

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

  5. Plan new dynamic behaviour with the Htmx class for 11.3 and later, and review calls to Htmx::disable(), the HX-Trigger header and FormBuilderInterface::HTMX_REQUEST before Drupal 12.

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