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.
Prerequisites
Section titled “Prerequisites”- A custom module installed on a Drupal 11 site.
- Hooks and events for how alter hooks work; Render API and the theming pipeline for the render arrays that form arrays are a special case of.
- For new forms: a route, per Routes and controllers.
Alter an existing form
Section titled “Alter an existing form”-
Find the form’s ID. Every rendered Drupal form carries it in a hidden input: view the page source and read the
valueof<input type="hidden" name="form_id">. For class-based forms this is whatgetFormId()returns, for examplesystem_site_information_settings. -
Implement
hook_form_FORM_ID_alter()as a method in your module’s hook class (any class undersrc/Hook/with#[Hook]attributes; see Convert procedural hooks to classes). The form ID replacesFORM_IDin 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_idinside it is a sign you wanted the targeted hook. -
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\FormBaseform handles its own submission. Your extra submit handler reads$form_stateand acts on it directly. - A
\Drupal\Core\Form\ConfigFormBaseform (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_formalters the add and edit forms of every content type at once. Form IDs per bundle also carry the operation:node_article_formis the article add form, andnode_article_edit_formthe edit form. To cover both for one bundle, use the base form hook and check$form_state->getFormObject()->getEntity()->bundle().
- A plain
Build a new form
Section titled “Build a new form”-
Route the form. Use
_formin the route’sdefaultsinstead of_controller(see theevent_rsvp.rsvp_formroute in Routes and controllers); Drupal builds, validates, and submits the form for you on that path. -
Define the form class. Extend
FormBase, give the form a unique ID, and describe the elements as a render array inbuildForm():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
#typeis a form element plugin (textfield,email,select,checkboxes, …). Elements declare their own client- and server-side checks:#required, and theemailtype’s format validation, run before your code does. Thevalueelement type carries server-side data through the request without rendering anything.FormBaseuses\Drupal\Core\DependencyInjection\AutowireTraitAdded in 11.4 the same way controllers do, so services arrive through a typed constructor. On earlier 11.x releases, writecreate()by hand, asRsvpFormdoes. -
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. -
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 (hereRsvpStorage), keeping the form a thin layer you can swap or test around.
Make a settings form
Section titled “Make a settings form”-
For a form whose values are configuration, extend
ConfigFormBaseinstead. 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 <?phpdeclare(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 inconfig/schema/; the configuration management workflow page covers both.
Update part of the form with Ajax
Section titled “Update part of the form with Ajax”-
Attach
#ajaxto the element whose change should update the page, and wrap the region to update in a container with a knownid: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 inbuildForm(), 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\AjaxResponsecarrying commands such as\Drupal\Core\Ajax\ReplaceCommandor\Drupal\Core\Ajax\MessageCommandwhen you need to change several things at once.
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.
Verify
Section titled “Verify”- 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.
Variations
Section titled “Variations”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.
Confirmation forms
Section titled “Confirmation forms”For destructive actions, extend ConfirmFormBase: implement getQuestion(), getCancelUrl(), and submitForm(), and you get the standard “Are you sure?” page.
Forms embedded outside a route
Section titled “Forms embedded outside a route”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.
Related
Section titled “Related”- Convert procedural hooks to classes for the
#[Hook]classes that alter hooks live in. - Routes and controllers for the
_formroute that exposes a form. - Render API and the theming pipeline for what
#type,#states, and friends are underneath. the Form generation topicandthe Ajax API topicon api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.