Drupal 8 form builder

As featured in issue 453 of TheWeeklyDrop.

Forms are a core part of any Drupal site. But how is a form actually made? The process is fairly simple, so let's take a stroll through Drupal 8's form code.

Creating a basic form

We'll be creating a basic form, then walking through how it works in the core form code. Most form code can be found in core/lib/Drupal/Core/Form. The classes we're immediately interested in are the FormBuilder and FormBase/FormInterface.

FormBuilder is what creates forms—FormInterface is the interface for classes to implement the form object, and FormBase is the class that all forms will extend.

Outside of the core form code, we are also interested in the render elements that make up a form. We can see the form render element (along with other relevant render elements) in core/lib/Drupal/Core/Render/Element.

When we create a form, we create a form object that extends FormBase. This must implement the methods from FormInterface:

buildForm(array $form, FormStateInterface $form_state);
validateForm(array $form, FormStateInterface $form_state);
submitForm(array $form, FormStateInterface $form_state);

We can see that the signatures of most of these methods are the same, which suggests they're very closely related.

The simplest form implementation is directly tied to a route for display—we can generate one with Drupal Console using drupal generate:form. This gives us a route that looks like this:

  path: '/example/form/example'
    _form: '\Drupal\example\Form\ExampleForm'
    _title: 'ExampleForm'
    _access: 'TRUE'

And a form that looks like this (simplified for brevity):

namespace Drupal\example_widget\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

class ExampleForm extends FormBase {

  public function getFormId() {
    return 'example_form';

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form = [];

    $form['text'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Text'),
      '#weight' => '0',

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),

    return $form;

  public function validateForm(array &$form, FormStateInterface $form_state) {
    // ...

  public function submitForm(array &$form, FormStateInterface $form_state) {
    // ...


We can see that the route uses _form rather than the usual _controller default. These requests are handled by the HtmlFormController, but how does this know which requests to handle?

Form Controller

We can see some form services defined in core.services.yml:

  class: Drupal\Core\Controller\HtmlFormController
  arguments: ['@http_kernel.controller.argument_resolver', '@form_builder', '@class_resolver']
  class: Drupal\Core\Entity\HtmlEntityFormController
  arguments: ['@http_kernel.controller.argument_resolver', '@form_builder', '@entity_type.manager']

These are added as responders via a Route Enhancer in core/lib/Drupal/Core/Routing/Enhancer/FormRouteEnhancer.php:

 * Enhancer to add a wrapping controller for _form routes.
class FormRouteEnhancer implements EnhancerInterface {

   * Returns whether the enhancer runs on the current route.
   * @param \Drupal\Core\Routing\Enhancer\Route $route
   *   The current route.
   * @return bool
  protected function applies(Route $route) {
    return $route->hasDefault('_form') && !$route->hasDefault('_controller');

   * {@inheritdoc}
  public function enhance(array $defaults, Request $request) {
    $route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
    if (!$this->applies($route)) {
      return $defaults;

    $defaults['_controller'] = 'controller.form:getContentResult';
    return $defaults;


We can see this applies to any route that has a _form default without a _controller. This then adds a _controller value of controller.form:getContentResult.

This basically makes our route above a shorthand for:

  path: '/example/form/example'
    _controller: 'controller.form:getContentResult'
    _form: '\Drupal\example_widget\Form\ExampleForm'
    _title: 'ExampleForm'
    _access: 'TRUE'

The basic retrieval of the form object is straightforward—in FormController->getContentResult():

public function getContentResult(Request $request, RouteMatchInterface $route_match) {
  $form_arg = $this->getFormArgument($route_match);
  $form_object = $this->getFormObject($route_match, $form_arg);

  // Add the form and form_state to trick the getArguments method of the
  // controller resolver.
  $form_state = new FormState();
  $request->attributes->set('form', []);
  $request->attributes->set('form_state', $form_state);
  $args = $this->argumentResolver->getArguments($request, [$form_object, 'buildForm']);

  // Remove $form and $form_state from the arguments, and re-index them.
  unset($args[0], $args[1]);
  $form_state->addBuildInfo('args', array_values($args));

  return $this->formBuilder->buildForm($form_object, $form_state);

First $this->getFormObject() is called with the value of the _form default we supplied in our route definition, which returns an instance of our form class via the Drupal ClassResolver. This also deals with any dependency injection if our form class implements ContainerInjectionInterface. The request is then modified to add our newly created form object, and an empty FormState object, before passing the $request to the argument resolver.

The argument resolver calls the buildForm() method on our form class, which returns the $form render array and updated $form_state object.

After this, the form render array and form state object are passed through to the buildForm() method on $this->formBuilder, which will almost always be the core FormBuilder. You could override this by creating a new form_builder service to supersede the one in core.services.yml:

  class: Drupal\Core\Form\FormBuilder
    - '@form_validator'
    - '@form_submitter'
    - '@form_cache'
    - '@module_handler'
    - '@event_dispatcher'
    - '@request_stack'
    - '@class_resolver'
    - '@element_info'
    - '@theme.manager'
    - '@?csrf_token'

Form builder

FormBuilder is a bit of a kitchen-sink class, implementing many related interfaces:

class FormBuilder implements
  TrustedCallbackInterface {

By the time we get to $this->formBuilder->buildForm(), the FormController has already resolved the form into a class. If this wasn't the case, $form_arg would be a string representing the class, which would be instantiated by getFormId():

public function getFormId($form_arg, FormStateInterface &$form_state) {
  // If the $form_arg is the name of a class, instantiate it. Don't allow
  // arbitrary strings to be passed to the class resolver.
  if (is_string($form_arg) && class_exists($form_arg)) {
    $form_arg = $this->classResolver->getInstanceFromDefinition($form_arg);

  if (!is_object($form_arg) || !($form_arg instanceof FormInterface)) {
    throw new \InvalidArgumentException("The form argument $form_arg is not a valid form.");

  // Add the $form_arg as the callback object and determine the form ID.
  if ($form_arg instanceof BaseFormIdInterface) {
    $form_state->addBuildInfo('base_form_id', $form_arg->getBaseFormId());
  return $form_arg->getFormId();

There are some confusing naming conventions here which are worth clarifying as we move forward—we have a few different variables we'll see related to forms:

  • $form_id: the string form ID as returned by the form class getFormId() method
  • $form: the form structure array, representing the renderable form
  • $form_state: the current state of the form, containing any submitted values and related data
  • The form object (here represented as $form_arg): an instance of current form class, stored on the $form_state and accessible via $form_state->getFormObject()

The most useful thing to be aware of is the distinction between $form as the renderable form structure, and the form object as an instance of the form class we've defined.

With that out of the way, let's return to the FormBuilder and buildForm(). After retrieving the form ID and resolving any arguments along the way it next deals with the current request, setting input values from the appropriate request type:

$request = $this->requestStack->getCurrentRequest();

// Inform $form_state about the request method that's building it, so that
// it can prevent persisting state changes during HTTP methods for which
// that is disallowed by HTTP: GET and HEAD.

// Initialize the form's user input. The user input should include only the
// input meant to be treated as part of what is submitted to the form, so
// we base it on the form's method rather than the request's method. For
// example, when someone does a GET request for
// /node/add/article?destination=foo, which is a form that expects its
// submission method to be POST, the user input during the GET request
// should be initialized to empty rather than to ['destination' => 'foo'].
$input = $form_state->getUserInput();
if (!isset($input)) {
  $input = $form_state->isMethodType('get') ? $request->query->all() : $request->request->all();

It then deals with batch processing and caching, before getting into the meat of the form processing. If we have a new form, as opposed to retrieving a value from the cache, buildForm() calls out to two two other methods:

$form = $this->retrieveForm($form_id, $form_state);
$this->prepareForm($form_id, $form, $form_state);

First it calls retrieveForm, which adds the $form_id to the $form_state (available via $form_state->getBuildInfo()['form_id']). It then copies the build info from the form (which at this point looks something like this) and supplies to the form callback function:

$args = [
  'args' => [],
  'files' => [],
  'callback_object' => '...', // instance of Drupal\example\Form\ExampleForm
  'form_id' => 'example_form',
$callback = [$form_state->getFormObject(), 'buildForm'];

$form = [];
// Assign a default CSS class name based on $form_id.
// This happens here and not in self::prepareForm() in order to allow the
// form constructor function to override or remove the default class.
$form['#attributes']['class'][] = Html::getClass($form_id);
// Same for the base form ID, if any.
if (isset($build_info['base_form_id'])) {
  $form['#attributes']['class'][] = Html::getClass($build_info['base_form_id']);

// We need to pass $form_state by reference in order for forms to modify it,
// since call_user_func_array() requires that referenced variables are
// passed explicitly.
$args = array_merge([$form, &$form_state], $args);

$form = call_user_func_array($callback, $args);

At this point, $form is not very interesting:

$form = [
  '#attributes' => [
    'class' => [
      0 => 'example-form',

The build info is merged into the $form and $form_state, and these are then passed to the buildForm() method on the form class. After the form is built by the form class, retrieveForm returns a basic form that looks something like this:

array (
  '#attributes' => 
  array (
    'class' => 
    array (
      0 => 'example-form',
  'text' => 
  array (
    '#type' => 'textarea',
    '#title' => 'Text',
    '#weight' => '0',
  'submit' => 
  array (
    '#type' => 'submit',
    '#value' => 'Submit',
  '#form_id' => 'example_form',

(I've elided some translatable values to strings for the purposes of simplicity).

After this, the nascent form is passed to prepareForm(). This first sets up the form array to be rendered as a Form render element:

$form['#type'] = 'form';

This is another shortcut—any render array that is returned from our form class buildForm() method is implicitly a form.

After this it does a bunch of safety checking and setting of CSRF tokens, which we're not going to get into here as it's not something you ever need to change. One part I do want to touch on is that some of this processing is not applied if the form has been submitted programmatically via submitForm(), which may be relevant to some of your form processing:

if ($form_state->isProgrammed() || (isset($form['#token']) && $form['#token'] === FALSE)) {

We also see form IDs being set in the form array:

if (isset($form_id)) {
  $form['form_id'] = [
    '#type' => 'hidden',
    '#value' => $form_id,
    '#id' => Html::getUniqueId("edit-$form_id"),
    // Form processing and validation requires this value, so ensure the
    // submitted form value appears literally, regardless of custom #tree
    // and #parents being set elsewhere.
    '#parents' => ['form_id'],
if (!isset($form['#id'])) {
  $form['#id'] = Html::getUniqueId($form_id);
  // Provide a selector usable by JavaScript. As the ID is unique, its not
  // possible to rely on it in JavaScript.
  $form['#attributes']['data-drupal-selector'] = Html::getId($form_id);

$form += $this->elementInfo->getInfo('form');
$form += ['#tree' => FALSE, '#parents' => []];
$form['#validate'][] = '::validateForm';
$form['#submit'][] = '::submitForm';

$build_info = $form_state->getBuildInfo();
// If no #theme has been set, automatically apply theme suggestions.
// The form theme hook itself, which is rendered by form.html.twig,
// is in #theme_wrappers. Therefore, the #theme function only has to care
// for rendering the inner form elements, not the form itself.
if (!isset($form['#theme'])) {
  $form['#theme'] = [$form_id];
  if (isset($build_info['base_form_id'])) {
    $form['#theme'][] = $build_info['base_form_id'];

And we get some base element info added according to the current active theme from the ElementInfoManager. These values can be seen on the relevant render element in the getInfo() method. This will look something like:

  '#method' => 'post',
  '#theme_wrappers' => [
    0 => 'form',
  '#type' => 'form',
  '#defaults_loaded' => true,

Two important attributes are set if they don't already exist:

$form += ['#tree' => FALSE, '#parents' => []];

Setting #tree determines whether the form structure is flattened or not, which is important if you have multiple elements with the same name in a fieldset or similar structure. The value of #parents tells the form renderer if the element has a parent, and you'll see this later on individual form elements. For more information about what these values do, see the Drupal documentation.

Here we also see the default validate and submit handlers set for the FormBuilder:

$form['#validate'][] = '::validateForm';
$form['#submit'][] = '::submitForm';

Finally, a #theme suggestion is added to the form structure if one doesn't already exist:

'#theme' => [
  0 => 'example_form',

And module and theme alter hooks are invoked for the built form structure:

// Invoke hook_form_alter(), hook_form_BASE_FORM_ID_alter(), and
// hook_form_FORM_ID_alter() implementations.
$hooks = ['form'];
if (isset($build_info['base_form_id'])) {
  $hooks[] = 'form_' . $build_info['base_form_id'];
$hooks[] = 'form_' . $form_id;
$this->moduleHandler->alter($hooks, $form, $form_state, $form_id);
$this->themeManager->alter($hooks, $form, $form_state, $form_id);

Now we're done with the form generation in buildForm(). Next, we move to processForm():

$response = $this->processForm($form_id, $form, $form_state);

The processForm() method takes the user's input and saves it to the form state, as well as as setting up a blank values array for $form_state->setValues() to be populated later:


// With GET, these forms are always submitted if requested.
if ($form_state->isMethodType('get') && $form_state->getAlwaysProcess()) {
  $input = $form_state->getUserInput();
  if (!isset($input['form_build_id'])) {
    $input['form_build_id'] = $form['#build_id'];
  if (!isset($input['form_id'])) {
    $input['form_id'] = $form_id;
  if (!isset($input['form_token']) && isset($form['#token'])) {
    $input['form_token'] = $this->csrfToken->get($form['#token']);

We then jump into doBuildForm() which calls element #process functions. From the form documentation:

#process: (array) Array of callables or function names, which are called during form building.
          Arguments: $element, $form_state, $form.

Again this adds on default element info per the theme, and assigns some default properties if they aren't already set:

$element += [
  '#required' => FALSE,
  '#attributes' => [],
  '#title_display' => 'before',
  '#description_display' => 'after',
  '#errors' => NULL,

There's also some special processing that happens for the top-level form element. The action URL is forced to HTTPS if it's external, and the entire form is stored in $form_state for use in callbacks:

// Store a reference to the complete form in $form_state prior to building
// the form. This allows advanced #process and #after_build callbacks to
// perform changes elsewhere in the form.

If we have user input, this also performs a check against the CSRF token to ensure that it's a valid submission. If not, the inputs are all cleared and an error is set. After this some IDs are set for the rendering and JS access of the element.

Elements that have #input set have additional processing via handleInputElement(). This ensures that the element has a #name and #value, as well as setting some usability and accessibility properties. I won't go into detail as to what goes on in here, since it's not directly relevant to the general building of a form, but if you're interested in what happens to #input elements this is the place to look. The two notable bits of processing here happen at the end of the function.

First, if the element is a button (it has #is_button set) then the triggering element is set on the $form_state for later use:

// If the form was submitted by the browser rather than via Ajax, then it
// can only have been triggered by a button, and we need to determine
// which button within the constraints of how browsers provide this
// information.
if (!empty($element['#is_button'])) {
  // All buttons in the form need to be tracked for
  // \Drupal\Core\Form\FormState::cleanValues() and for the
  // self::doBuildForm() code that handles a form submission containing no
  // button information in \Drupal::request()->request.
  $buttons = $form_state->getButtons();
  $buttons[] = $element;
  if ($this->buttonWasClicked($element, $form_state)) {

Secondly, the element's value is set for $form_state->getValues() if it's not already set:

// Set the element's value in $form_state->getValues(), but only, if its key
// does not exist yet (a #value_callback may have already populated it).
if (!NestedArray::keyExists($form_state->getValues(), $element['#parents'])) {
  $form_state->setValueForElement($element, $element['#value']);

We return to doBuildForm() as this is the meat of our form processing. After processing input elements the #process callbacks are called:

// Allow for elements to expand to multiple elements, e.g., radios,
// checkboxes and files.
if (isset($element['#process']) && !$element['#processed']) {
  foreach ($element['#process'] as $callback) {
    $complete_form = &$form_state->getCompleteForm();
    $element = call_user_func_array($form_state->prepareCallback($callback), [&$element, &$form_state, &$complete_form]);
  $element['#processed'] = TRUE;

Any access requirements are also applied.

After this we see doBuildForm() invoked for all children of the current element—it will continue to be recursively called until the whole of the form tree is processed. It uses the helpful Element::children() method, which iterates through all array children with integer keys.

Within this loop we again see the element defaults applied from the elementInfo. Any access requirements are inherited from the parent, and #tree values are preserved for later rendering and processing. We also see the value of #parents set here for children, where this will be a full array of parents if #tree is set and an array consisting of the element key if not. However, #array_parents set below will always be a full parent array according to the form structure, regardless of the value of #tree. After dealing with weights and sorting, the call then recurses into doBuildForm().

After the child processing loop, any #after_build callbacks are invoked with the $element and $form_state.

Finally we return to processing for the top-level form once all other elements have been processed. The encoding is set if the form contains file elements, and some browser issues are fixed. It then looks at which button is used to submit the element, and sets the validation and submit handlers accordingly if any are attached directly to the submitting element:

$triggering_element = $form_state->getTriggeringElement();

// If the triggering element specifies "button-level" validation and
// submit handlers to run instead of the default form-level ones, then add
// those to the form state.
if (isset($triggering_element['#validate'])) {
if (isset($triggering_element['#submit'])) {

It also decides whether the triggering element causes the form to be submitted. This value is only TRUE for elements of #type 'submit' and not 'button', even though both of these are rendered as <input /> elements. There isn't a core <button /> render element—this is a long-running gripe that many have with Drupal forms, as buttons are useful for styling purposes but are not used in Drupal by default.

// If the triggering element executes submit handlers, then set the form
// state key that's needed for those handlers to run.
if (!empty($triggering_element['#executes_submit_callback'])) {

And with a final bit of tidying up for buttons, doBuildForm() is done.


Now that the form and form state are completely built, we can move on to input processing. If the form is being submitted (i.e. if $form_state->isProcessingInput() is TRUE) then $this->formValidator->validateForm() is invoked, where formValidator is the form_validator service:

  class: Drupal\Core\Form\FormValidator
    - '@request_stack'
    - '@string_translation'
    - '@csrf_token'
    - '@logger.channel.form'
    - '@form_error_handler'

After checking for previous validations, validateForm() again checks the CSRF token. If this isn't valid, the form validation automatically fails. Assuming this is correct, we call off to three callbacks on the FormValidator:

// Recursively validate each form element.
$this->doValidateForm($form, $form_state, $form_id);
$this->finalizeValidation($form, $form_state, $form_id);
$this->handleErrorsWithLimitedValidation($form, $form_state, $form_id);

Much like doBuildForm(), doValidateForm() is called recursively for all form elements. For elements with #needs_validation set to true it first calls any built-in validation based on the element's attributes. From the FormBuilder, this will be set to TRUE if the element has a #value or if it's a #required element.

Before running the full validation, the form checks if #limit_validation_errors is set in determineLimitValidationErrors(). This will allow handlers to be triggered without running a full validation for particular action buttons.

If this isn't the case, the form gets fully validated. First required elements are checked for values; if empty, the #required_but_empty flag is set so that an appropriate error message can be shown.

After this we execute any validation handlers set:

// Call user-defined form level validators.
if (isset($form_id)) {
  $this->executeValidateHandlers($elements, $form_state);
// Call any element-specific validators. These must act on the element
// #value data.
elseif (isset($elements['#element_validate'])) {
  foreach ($elements['#element_validate'] as $callback) {
    $complete_form = &$form_state->getCompleteForm();
    call_user_func_array($form_state->prepareCallback($callback), [&$elements, &$form_state, &$complete_form]);

For the top-level form, we call executeValidationHandlers(). This either calls validation handlers set to the $form_state via $form_state->setValidationHandlers() or any form-level handlers in $form['#validate'] (but not both). If running the handlers on $form['#validate'] it will at minimum run the default ::validateForm() on the form class. If we're validating an element, call any #element_validate callbacks. All of these callbacks have to be functions or static methods. If they're a string starting with :: they're automatically called on the form object.

After these validation handlers are called, any required element errors are set on the $form_state, and setLimitValidationErrors() is turned off to begin validating the next form element.


If the form passes validation, the next step is to submit the data. This time we call out to the form_submitter service via doSubmitForm():

  class: Drupal\Core\Form\FormSubmitter
  arguments: ['@request_stack', '@url_generator']

This does some processing that's very similar to that found in doValidateForm(). First it checks for submit handlers set on the $form_state for the triggering element, and if not uses handlers set on the form #submit attribute:

// If there was a button pressed, use its handlers.
$handlers = $form_state->getSubmitHandlers();
// Otherwise, check for a form-level handler.
if (!$handlers && !empty($form['#submit'])) {
  $handlers = $form['#submit'];

It also deals with processing batches if a batch processing is set for the form.

After calling form submission handlers and any batch processing, the submit handler may return a Response object or cause the form to redirect. If a redirect occurs at this point the form processing ends.

If the form doesn't redirect then the form processing continues in processForm(). After submission the form cache is cleared. If the form returns a Response then this is returned, and the form submission ends. Remember that this is all being invoked from the FormController, so this is effectively returning the controller response. If this is a multi-step process then the form is rebuilt.

Finishing up

Now we're back in FormBuilder->buildForm(). At this point we start exiting the form processing altogether by throwing exceptions to direct the control flow. For excessively large requests, a BrokenPostRequestException is thrown. For AJAX forms a FormAjaxException is thrown to interrupt the form rendering. For forms that return a Response an EnforcedResponseException is thrown, to likewise prevent rendering.

// In case the post request exceeds the configured allowed size
// (post_max_size), the post request is potentially broken. Add some
// protection against that and at the same time have a nice error message.
if ($ajax_form_request && !$request->request->has('form_id')) {
  throw new BrokenPostRequestException($this->getFileUploadMaxSize());

// After processing the form, if this is an AJAX form request, interrupt
// form rendering and return by throwing an exception that contains the
// processed form and form state. This exception will be caught by
// \Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber::onException() and
// then passed through
// \Drupal\Core\Form\FormAjaxResponseBuilderInterface::buildResponse() to
// build a proper AJAX response.
// Only do this when the form ID matches, since there is no guarantee from
// $ajax_form_request that it's an AJAX request for this particular form.
if ($ajax_form_request && $form_state->isProcessingInput() && $request->request->get('form_id') == $form_id) {
  throw new FormAjaxException($form, $form_state);

// If the form returns a response, skip subsequent page construction by
// throwing an exception.
// @see Drupal\Core\EventSubscriber\EnforcedFormResponseSubscriber
// @todo Exceptions should not be used for code flow control. However, the
//   Form API does not integrate with the HTTP Kernel based architecture of
//   Drupal 8. In order to resolve this issue properly it is necessary to
//   completely separate form submission from rendering.
//   @see https://www.drupal.org/node/2367555
if ($response instanceof Response) {
  throw new EnforcedResponseException($response);

Otherwise, at this point the $form render array is returned and ends up being the final return value from FormController->getContentResult(). We then have a complete render array with a top-level element of type #form, so this is rendered as with any render array.

In conclusion

This was a quick run through the form building process. It's not super complicated one you get into it—and knowing how it works can help when you're building or altering your own forms.

If you've found this helpful, or if you have any suggestions, let me know on Twitter.