Building forms is part of a Drupal developer's daily life. Forms are nested render arrays in every version of Drupal, but where Drupal 7 defined them in functions, Drupal 8 and later define them in a form class.
In this post we'll build a custom form with two fields, a text field and a checkbox, validate them, echo the values back in a message, and redirect the user to the front page. The original Drupal 8 example is at github.com/KarimBoudjema/Drupal8-ex-custom-form; the code here is rewritten for Drupal 11.
web/modules/custom/ex81/
|-- ex81.info.yml
|-- ex81.routing.yml
`-- src
`-- Form
`-- HelloForm.php
In Drupal 11 every form is a class implementing \Drupal\Core\Form\FormInterface, which defines four methods:
- getFormId(): the form's unique ID;
- buildForm(): runs when the form is requested; returns the
$formrender array; - validateForm(): runs on submit; checks the values and optionally raises errors;
- submitForm(): runs on a valid submission to process the values.
We'll build the whole class step by step, but you can scaffold the skeleton first with Drush 13:
drush generate module
drush generate form:simple
1. The form class and its ID
Create src/Form/HelloForm.php with a class extending the abstract FormBase (which implements FormInterface). Start with getFormId(), returning a unique machine name:
<?php
declare(strict_types=1);
namespace Drupal\ex81\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* A simple two-field custom form.
*/
final class HelloForm extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'ex81_hello_form';
}
}
2. Build the form
Next comes buildForm(), which defines a description, the text field, the checkbox, and a submit button, then returns the render array:
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['description'] = [
'#type' => 'item',
'#markup' => $this->t('Please enter the title and accept the terms of use of the site.'),
];
$form['title'] = [
'#type' => 'textfield',
'#title' => $this->t('Title'),
'#description' => $this->t('Enter the title of the book. It must be at least 10 characters long.'),
'#required' => TRUE,
];
$form['accept'] = [
'#type' => 'checkbox',
'#title' => $this->t('I accept the terms of use of the site'),
'#description' => $this->t('Please read and accept the terms of use.'),
];
// Wrap submit handlers in an "actions" element so they are styled
// consistently and other modules can add buttons.
$form['actions'] = [
'#type' => 'actions',
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
return $form;
}
3. Validate the form
Now validateForm(). We read submitted values with $form_state->getValue('key') and raise errors with $form_state->setErrorByName(). Here we reject a title shorter than 10 characters, and an unchecked box:
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
$title = (string) $form_state->getValue('title');
if (mb_strlen($title) < 10) {
$form_state->setErrorByName('title', $this->t('The title must be at least 10 characters long.'));
}
if (empty($form_state->getValue('accept'))) {
$form_state->setErrorByName('accept', $this->t('You must accept the terms of use to continue.'));
}
}
4. Process the values
Finally, submitForm(). This is where you would save to the database, call an external API, or hand the data off to a service; here we simply display the values and redirect to the front page with $form_state->setRedirect().
One important Drupal 11 change: drupal_set_message() was removed years ago. Use the messenger service instead. FormBase already exposes it as $this->messenger():
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->messenger()->addMessage($this->t('Title: @title', [
'@title' => $form_state->getValue('title'),
]));
$this->messenger()->addMessage($this->t('Accepted: @accept', [
'@accept' => $form_state->getValue('accept') ? $this->t('yes') : $this->t('no'),
]));
$form_state->setRedirect('<front>');
}
If you needed to store these values in configuration you would extend ConfigFormBase instead, as we saw in the config form post.
5. Route to the form
Add a route in ex81.routing.yml. Because this is a form, use the _form key (not _controller) so Drupal invokes the form builder:
ex81.hello_form:
path: '/ex81/helloform'
defaults:
_form: 'Drupal\ex81\Form\HelloForm'
_title: 'Simple custom form example'
requirements:
_permission: 'access content'
Navigate to /ex81/helloform and test it. Nothing complicated, right?
6. Bonus: inject the messenger service properly
Calling $this->messenger() is convenient, but for anything beyond the trivial you should inject your dependencies explicitly. Because FormBase implements ContainerInjectionInterface, the core AutowireTrait works here exactly as it does in a controller. Declare the dependency as a promoted, type-hinted constructor property and skip the hand-written create() method entirely:
<?php
declare(strict_types=1);
namespace Drupal\ex82\Form;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
/**
* The same form, with the messenger service injected via autowiring.
*/
final class HelloForm extends FormBase {
use AutowireTrait;
public function __construct(
protected readonly MessengerInterface $messengerService,
) {}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'ex82_hello_form';
}
// getFormId(), buildForm() and validateForm() are identical to ex81.
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->messengerService->addMessage($this->t('Title: @title', [
'@title' => $form_state->getValue('title'),
]));
$form_state->setRedirect('<front>');
}
}
The MessengerInterface type-hint is enough for the container to wire in the messenger service: no #[Autowire] attribute is needed for a service that has a matching autowiring alias.
Recap. We created a module and a FormBase class, gave it a unique ID with getFormId(), defined its fields in buildForm(), validated them in validateForm() with setErrorByName(), processed them in submitForm() using the messenger service and setRedirect(), and exposed it with a _form route. The Drupal 11 rewrite swaps Drupal Console for drush generate, replaces the removed drupal_set_message() with the messenger service, and adds strict types and return type declarations.
Forms are everywhere in Drupal, so it pays to be comfortable with them. What's the trickiest form you've had to build? Tell us in the comments.
More info
- Introduction to the Form API (Drupal docs)
- Form and render element reference (Drupal API)