Skip to content

How-to guideFor module developersDrupal 11

Build and alter forms

Most form work on a Drupal project is changing a form that already exists: a node form, a search form, a contrib module’s settings page. This page starts there, then covers building your own forms, validation and submission, and Ajax-updated elements.

Change any form on the site without touching its owning module, or ship a form of your own with validation, submission, and access control handled by the Form API. If editors should compose the thing you are building (fields, layout, content), that is an entity form driven by field configuration, not a custom form; see Entities, bundles, and fields.

  1. Find the form’s ID. Every rendered Drupal form carries it in a hidden input: view the page source and read the value of <input type="hidden" name="form_id">. For class-based forms this is what getFormId() returns, for example system_site_information_settings.

  2. Implement hook_form_FORM_ID_alter() as a method in your module’s hook class (any class under src/Hook/ with #[Hook] attributes; see Convert procedural hooks to classes). The form ID replaces FORM_ID in the hook name:

    web/modules/custom/docs_snippets/src/Hook/DocsSnippetsHooks.php
    /**
    * Implements hook_form_FORM_ID_alter() for the site information form.
    *
    * Targeted alter hooks like this one beat a hook_form_alter() with an
    * if-tree: Drupal only calls you for the form you care about.
    */
    #[Hook('form_system_site_information_settings_alter')]
    public function siteInformationFormAlter(array &$form, FormStateInterface $form_state): void {
    // Add a description to an existing element.
    $form['site_information']['site_slogan']['#description'] =
    $this->t('Keep it short: the slogan appears in browser tabs.');
    // Add a custom validator. It runs alongside the form's own ones.
    $form['#validate'][] = [static::class, 'validateSlogan'];
    }
    /**
    * Validates that the slogan is not too long.
    *
    * @param array $form
    * The form render array.
    * @param \Drupal\Core\Form\FormStateInterface $form_state
    * The form state.
    */
    public static function validateSlogan(array &$form, FormStateInterface $form_state): void {
    if (mb_strlen((string) $form_state->getValue('site_slogan')) > 80) {
    $form_state->setErrorByName('site_slogan', t('The slogan must be 80 characters or fewer.'));
    }
    }

    The form array arrives by reference; change it in place and return nothing. You can add elements, hide them ('#access' => FALSE), change titles and descriptions, and append your own validators ($form['#validate'][]) and submit handlers ($form['#submit'][]). Drupal runs them alongside the form’s own.

    Use the generic hook_form_alter() only when the change applies to many forms at once; it runs for every form on the site, and an if-tree on $form_id inside it is a sign you wanted the targeted hook.

  3. Know which of the three kinds of form you are altering. They look identical in the alter hook, but their data flows differ, so where the values end up differs:

    • A plain \Drupal\Core\Form\FormBase form handles its own submission. Your extra submit handler reads $form_state and acts on it directly.
    • A \Drupal\Core\Form\ConfigFormBase form (most settings pages) writes values to configuration. To persist a setting you add, also write it to config in your submit handler.
    • An entity form (node, term, user edit pages) maps values onto an entity that is then saved. All entity forms of one type share a base form ID, so hook_form_BASE_FORM_ID_alter() with, for example, node_form alters the add and edit forms of every content type at once. Form IDs per bundle also carry the operation: node_article_form is the article add form, and node_article_edit_form the edit form. To cover both for one bundle, use the base form hook and check $form_state->getFormObject()->getEntity()->bundle().
  1. Route the form. Use _form in the route’s defaults instead of _controller (see the event_rsvp.rsvp_form route in Routes and controllers); Drupal builds, validates, and submits the form for you on that path.

  2. Define the form class. Extend FormBase, give the form a unique ID, and describe the elements as a render array in buildForm():

    web/modules/custom/event_rsvp/src/Form/RsvpForm.php
    /**
    * {@inheritdoc}
    */
    public function buildForm(array $form, FormStateInterface $form_state, ?NodeInterface $node = NULL): array {
    // Stash the node ID so validate and submit handlers can read it back.
    $form['nid'] = [
    '#type' => 'value',
    '#value' => $node?->id(),
    ];
    $form['mail'] = [
    '#type' => 'email',
    '#title' => $this->t('Email address'),
    '#description' => $this->t("We'll only use this to confirm your spot."),
    '#required' => TRUE,
    '#default_value' => $this->currentUser()->getEmail() ?? '',
    ];
    $form['actions'] = [
    '#type' => 'actions',
    ];
    $form['actions']['submit'] = [
    '#type' => 'submit',
    '#value' => $this->t('RSVP'),
    ];
    return $form;
    }

    Each #type is a form element plugin (textfield, email, select, checkboxes, …). Elements declare their own client- and server-side checks: #required, and the email type’s format validation, run before your code does. The value element type carries server-side data through the request without rendering anything. FormBase uses \Drupal\Core\DependencyInjection\AutowireTrait Added in 11.4 the same way controllers do, so services arrive through a typed constructor. On earlier 11.x releases, write create() by hand, as RsvpForm does.

  3. Validate in validateForm(). Only business rules belong here; element-level format checks already happened:

    web/modules/custom/event_rsvp/src/Form/RsvpForm.php
    /**
    * {@inheritdoc}
    */
    public function validateForm(array &$form, FormStateInterface $form_state): void {
    $nid = (int) $form_state->getValue('nid');
    $mail = (string) $form_state->getValue('mail');
    // The #type 'email' element already validated the address format.
    // Business rules belong here: one RSVP per address per event.
    if ($this->rsvpStorage->exists($nid, $mail)) {
    $form_state->setErrorByName('mail', $this->t('%mail has already responded to this event.', ['%mail' => $mail]));
    }
    }

    setErrorByName() attaches the message to a specific element and blocks submission. Raise every error you can find in one pass rather than one per round trip.

  4. Act in submitForm(). It runs only when validation passed:

    web/modules/custom/event_rsvp/src/Form/RsvpForm.php
    /**
    * {@inheritdoc}
    */
    public function submitForm(array &$form, FormStateInterface $form_state): void {
    $this->rsvpStorage->add(
    (int) $form_state->getValue('nid'),
    (int) $this->currentUser()->id(),
    (string) $form_state->getValue('mail'),
    );
    $message = $this->config('event_rsvp.settings')->get('confirmation_message');
    $this->messenger()->addStatus($message);
    $form_state->setRedirect('entity.node.canonical', ['node' => $form_state->getValue('nid')]);
    }

    A form that does real work in submitForm() should delegate to a service (here RsvpStorage), keeping the form a thin layer you can swap or test around.

  1. For a form whose values are configuration, extend ConfigFormBase instead. Declare which config objects the form edits, and map each element to a config value with #config_target; the base class loads the defaults, validates the values against the config schema, saves them, and adds the Save configuration button and the standard admin appearance:

    web/modules/custom/event_rsvp/src/Form/RsvpSettingsForm.php
    <?php
    declare(strict_types=1);
    namespace Drupal\event_rsvp\Form;
    use Drupal\Core\Config\ConfigFactoryInterface;
    use Drupal\Core\Config\TypedConfigManagerInterface;
    use Drupal\Core\Entity\EntityTypeManagerInterface;
    use Drupal\Core\Form\ConfigFormBase;
    use Drupal\Core\Form\ConfigTarget;
    use Drupal\Core\Form\FormStateInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Configures which content types accept RSVPs.
    */
    final class RsvpSettingsForm extends ConfigFormBase {
    /**
    * Constructs the settings form.
    *
    * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
    * The config factory.
    * @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfigManager
    * The typed config manager.
    * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
    * The entity type manager, used to list content types.
    */
    public function __construct(
    ConfigFactoryInterface $config_factory,
    TypedConfigManagerInterface $typedConfigManager,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    ) {
    parent::__construct($config_factory, $typedConfigManager);
    }
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container): static {
    return new static(
    $container->get('config.factory'),
    $container->get('config.typed'),
    $container->get('entity_type.manager'),
    );
    }
    /**
    * {@inheritdoc}
    */
    public function getFormId(): string {
    return 'event_rsvp_settings_form';
    }
    /**
    * {@inheritdoc}
    */
    protected function getEditableConfigNames(): array {
    return ['event_rsvp.settings'];
    }
    /**
    * {@inheritdoc}
    */
    public function buildForm(array $form, FormStateInterface $form_state): array {
    $options = [];
    $node_types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
    foreach ($node_types as $type) {
    $options[$type->id()] = $type->label();
    }
    $form['enabled_types'] = [
    '#type' => 'checkboxes',
    '#title' => $this->t('RSVP-enabled content types'),
    '#description' => $this->t('Visitors can RSVP to content of these types.'),
    '#options' => $options,
    // Checkboxes submit [type => type|0]; store only the checked ones.
    '#config_target' => new ConfigTarget(
    'event_rsvp.settings',
    'enabled_types',
    toConfig: fn (array $value): array => array_keys(array_filter($value)),
    ),
    ];
    $form['confirmation_message'] = [
    '#type' => 'textfield',
    '#title' => $this->t('Confirmation message'),
    '#config_target' => 'event_rsvp.settings:confirmation_message',
    '#required' => TRUE,
    ];
    return parent::buildForm($form, $form_state);
    }
    }

    Ship defaults for that config object in config/install/, and a schema for it in config/schema/; the configuration management workflow page covers both.

  1. Attach #ajax to the element whose change should update the page, and wrap the region to update in a container with a known id:

    web/modules/custom/docs_snippets/modules/module_howtos_a/src/Form/FeedbackForm.php
    /**
    * {@inheritdoc}
    */
    public function buildForm(array $form, FormStateInterface $form_state): array {
    $form['topic'] = [
    '#type' => 'select',
    '#title' => $this->t('Topic'),
    '#options' => [
    'bug' => $this->t('Bug report'),
    'feature' => $this->t('Feature request'),
    ],
    // Changing this element fires an Ajax request that re-runs the form
    // build, then replaces the wrapper element with the callback's result.
    '#ajax' => [
    'callback' => '::updateHint',
    'wrapper' => 'topic-hint',
    ],
    ];
    // The wrapper the callback replaces. Build it from the CURRENT form
    // state: by the time the callback runs, this has already happened.
    $form['hint'] = [
    '#type' => 'container',
    '#attributes' => ['id' => 'topic-hint'],
    ];
    $hints = [
    'bug' => $this->t('Include steps to reproduce the problem.'),
    'feature' => $this->t('Describe the problem before the solution.'),
    ];
    $form['hint']['text'] = [
    '#markup' => $hints[$form_state->getValue('topic') ?? 'bug'],
    ];
    $form['message'] = [
    '#type' => 'textarea',
    '#title' => $this->t('Message'),
    '#required' => TRUE,
    ];
    $form['actions'] = ['#type' => 'actions'];
    $form['actions']['submit'] = [
    '#type' => 'submit',
    '#value' => $this->t('Send'),
    ];
    return $form;
    }
    /**
    * Ajax callback: returns the part of the form to put in the wrapper.
    *
    * The callback runs AFTER the form has been rebuilt with the new input,
    * so it only needs to pick out the fragment to send back. No logic here.
    *
    * @param array $form
    * The rebuilt form render array.
    * @param \Drupal\Core\Form\FormStateInterface $form_state
    * The form state.
    *
    * @return array
    * The form fragment that replaces the 'topic-hint' wrapper.
    */
    public function updateHint(array &$form, FormStateInterface $form_state): array {
    return $form['hint'];
    }

    The mechanics matter here: when the select changes, the browser posts the whole form, Drupal rebuilds it through buildForm() with the new values in $form_state, and only then calls the callback. So the callback contains no logic; the conditional rendering lives in buildForm(), which is also what makes the form work identically without JavaScript on first load. Instead of returning an element, a callback may return an \Drupal\Core\Ajax\AjaxResponse carrying commands such as \Drupal\Core\Ajax\ReplaceCommand or \Drupal\Core\Ajax\MessageCommand when you need to change several things at once.

Ajax rebuilds the whole form

When the watched element changes, the browser posts the entire form. Drupal runs buildForm again with the new values in form state, so the conditional element is already rebuilt. Only then does the Ajax callback run, and it returns the element that buildForm already built rather than computing new markup itself.

  • Alter: visit Administer → Configuration → System → Basic site settings/admin/config/system/site-information. The Slogan field shows your added description, and saving a slogan over 80 characters is rejected with your message.
  • New form: visit the form’s path; submit without required fields and see element errors; submit valid input and see your confirmation message.
  • Ajax: on your Ajax form’s page (the examples project serves this one at /module-howtos-a/feedback), change Topic and watch the hint text change without a page reload.

Conditional visibility without a server round trip

Section titled “Conditional visibility without a server round trip”

When one element should show or hide based on another and no server data is involved, #states does it in the browser:

'#states' => [
'visible' => [
':input[name="topic"]' => ['value' => 'bug'],
],
],

Reach for #ajax only when the server must compute something.

For destructive actions, extend ConfirmFormBase: implement getQuestion(), getCancelUrl(), and submitForm(), and you get the standard “Are you sure?” page.

To render a form inside other output (a block, a controller section), inject form_builder and call $this->formBuilder->getForm(MyForm::class). Do not instantiate form classes yourself; the form builder manages state, caching, and CSRF tokens.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.