Introduction

In this article, I will show how to build a custom form in Drupal 8. Basically, two things are needed to create a custom form:

  1. Define a route (address) where the custom form can be accessed (Step 1)
  2. Build the class that handles the custom form (Steps 2, 3, 4, 5, 6)

Prerequisites

It will be assumed that we have a custom module named my_custom_module. You can check Creating a custom module for Drupal 8 if you need a quick refresher on how to create one.

All the steps were tested on Drupal 8.8.0.

Step 1 - Define a route (form url)

To create a new route we need to first create the my_custom_module.routing.yml file inside the my_custome_module folder

// Go to the my_custom_module folder.
cd web/modules/custom/my_custom_module

// Create the my_custom_module.routing.yml file.
touch my_custom_module.routing.yml

Next edit my_custom_module.routing.yml file to look like this

my_custom_module.simple_custom_module:
  path: '/simple-custom-form'
  defaults:
    _form: '\Drupal\my_custom_module\Form\SimpleCustomForm'
  requirements:
    _access: 'TRUE'

The first line is the machine name of the route (route id) and was chosen arbitrarily.

The path is the web address (url) assigned to access this route.

The _form key defines the class that will handle this custom form.

Finally, _access: 'true' makes this route accessible to any user.

Step 2 - Create the form class

Create the my_custom_module\src\Form\SimpleCustomForm.php file with the following code in it.

<?php

namespace Drupal\my_custom_module\Form;

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

class SimpleCustomForm extends FormBase {
  public function getFormId() {
    // Here we set a unique form id
    return 'simple_custom_form';
  }

  public function buildForm(array $form, FormStateInterface $form_state) {
    // Here we build the form UI.
  }

  public function validateForm(array &$form, FormStateInterface $form_state) {
    // Here we decide whether the form can be forwarded to the submitForm()
    // function or should be sent back to the user to fix some information.
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    // Here we process the form and do what needs to be done.
  }

}

This form class has 3 essential functions:

  1. buildForm(): builds the form that will be displayed on the browser.
  2. validateForm(): when the user submits the form, this function validates the form submission (for example, check that the password value is longer than 8).
  3. submitForm(): this is called after validateForm() successfully validates the form submission, enabling us to take action with the submitted values.

We will flesh out this class on steps 3, 4, 5, and 6. If you want to jump to the final look of this class, go to the Conclusion section at the bottom of this page.

Step 3 - Building the buildForm() function

In this step, we will build the buildForm() function with some basic form fields.

We are going to add different types of fields on this form for the purposes of this example. A complete list of fields availabe can be found on the official Drupal website. It's worth noting that additional types of fields can be installed by third-party modules.

public function buildForm(array $form, FormStateInterface $form_state, $username = NULL) {
  // Textfield form element.
  $form['custom_text_field'] = [
    '#type' => 'textfield',
    '#title' => 'Text field:',
    '#required' => TRUE,
  ];

  // Number field form element.
  $form['custom_number_field'] = [
    '#type' => 'number',
    '#title' => 'Number:',
  ];

  // Password form element.
  $form['custom_password_field'] = [
    '#type' => 'password',
    '#title' => 'Password',
    '#size' => 25,
  ];

  // Date form element.
  $form['custom_date_field'] = [
    '#type' => 'date',
    '#title' => 'Date:',
  ];

  // Radio buttons form element.
  $form['custom_radio_field'] = [
    '#type' => 'radios',
    '#title' => 'Radio buttons:',
    '#options' => [
      'apple' => 'Apple',
      'banana' => 'Banana',
      'orange' => 'Orange',
    ],
  ];

  return $form;
}

The custom_text_field field has a '#required' => TRUE property, meaning that this field will be showing as required and will be enforced by the browser to have some value before submitting it. The '#required' => TRUE can be added to other form fields.

A clever user can trick this enforcement by the browser and submit a form with an empty required field. The next step shows how to add validation also on the server side so we don't' allow users to bypass required fields.

Step 4 - Build the form validation function

It's time to build the validateForm() function. In this example, we will enforce the submitted text value to be at least 4 characters long and the number field to have a maximum value of 100.

public function validateForm(array &$form, FormStateInterface $form_state) {
  // Limit text length to 4.
  $textfield_value = $form_state->getValue('custom_text_field');
  if (strlen($textfield_value) < 4) {
    $form_state->setErrorByName('custom_text_field', 'The text input must have at least 4 characters.');
  }

  // Limit maximum number value to 100.
  $number_value = $form_state->getValue('custom_number_field');
  if ($fnumber_value > 100) {
    $form_state->setErrorByName('custom_number_field', 'Then number value cannot be greater than 100.');
  }
}

Visit the form on the browser and try to submit a form with a text length less than 4 and(or) a number greater than 100. You should see an error message showing that the form values need to be fixed.

Step 5 - Build the form submission function

And finally the submitForm() function. Here we are going to simply show all the submitted values as a status message on the website.

public function submitForm(array &$form, FormStateInterface $form_state) {
  // Show all form values as status message.
  foreach ($form_state->getValues() as $key => $value) {
    \Drupal::messenger()->addStatus($key . ': ' . $value);
  }
}

Try submitting a form and you should see all the form values showing up as status message.

Step 6 - Set redirection (optional)

After a form submission, the user gets redirected to the same form page by default. In most of the cases this is not what we want.

In submitForm() you can set the redirect address with either $form_state->setRedirect() or $form_set->setRedirectUrl(). Check the documentation for FormStateInterface::setRediret and FormStateInterface::setRedirectUrl for more information on how to use them.

public function submitForm(array &$form, FormStateInterface $form_state) {
  // Show all form values as status message.
  foreach ($form_state->getValues() as $key => $value) {
    \Drupal::messenger()->addStatus($key . ': ' . $value);
  }

  // Redirect user to the front page.
  $form_state->setRedirect('<front>');
}

Conclusion

The SimpleCustomForm class in this example should look like this in the end:

<?php

namespace Drupal\my_custom_module\Form;

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

class SimpleCustomForm extends FormBase {
  public function getFormId() {
    // Here we set a unique form id
    return 'simple_custom_form';
  }

  public function buildForm(array $form, FormStateInterface $form_state, $username = NULL) {
    // Textfield form element
    $form['custom_text_field'] = [
      '#type' => 'textfield',
      '#title' => 'Text field:',
      '#required' => TRUE,
    ];

    // Number field form element
    $form['custom_number_field'] = [
      '#type' => 'number',
      '#title' => 'Number:',
    ];

    // Password form element
    $form['custom_password_field'] = [
      '#type' => 'password',
      '#title' => 'Password',
      '#size' => 25,
    ];

    // Date form element
    $form['custom_date_field'] = [
      '#type' => 'date',
      '#title' => 'Date:',
    ];

    // Radio buttons form element
    $form['custom_radio_field'] = [
      '#type' => 'radios',
      '#title' => 'Radio buttons:',
      '#options' => [
        'apple' => 'Apple',
        'banana' => 'Banana',
        'orange' => 'Orange',
      ],
    ];
  
    return $form;
  }

  public function validateForm(array &$form, FormStateInterface $form_state) {
    // Limit text length to 4.
    $textfield_value = $form_state->getValue('custom_text_field');
    if (strlen($textfield_value) < 4) {
      $form_state->setErrorByName('custom_text_field', 'The text input must have at least 4 characters.');
    }

    // Limit maximum number value to 100.
    $number_value = $form_state->getValue('custom_number_field');
    if ($number_value > 100) {
      $form_state->setErrorByName('custom_number_field', 'Then number value cannot be greater than 100.');
    }
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    foreach ($form_state->getValues() as $key => $value) {
      \Drupal::messenger()->addStatus($key . ': ' . $value);
    }

    $form_state->setRedirect('<front>');
  }

}

https://www.drupal.org/docs/8/api/form-api/conditional-form-fields