Multi-Step Forms in Drupal: Building a Wizard with setRebuild()for Drupal 11 , and 10

Last updated :  

Every form you've built in this topic so far has fit on one page. But think about a real signup wizard, a checkout flow, or a survey — they often need to collect information across several screens, remembering what you typed on step 1 while you're filling in step 2. Drupal has no separate "multi-step form" API for this. Instead, there's one clever trick: a single form class that rebuilds itself into different pages, using $form_state as its memory between rebuilds. This lesson walks through a real two-page wizard, start to finish.

What you'll learn in this lesson

  • How one buildForm() method can render two completely different pages
  • How $form_state->setRebuild(TRUE) keeps a form "alive" across multiple submissions
  • How to give a single button its own validation and submit logic with #validate and #submit
  • Why page 1's data has to be manually saved before moving to page 2
  • How a "Back" button can skip validation on purpose

The source file

Path: modules/form_api_example/src/Form/MultistepForm.php

<?php

namespace Drupal\form_api_example\Form;

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

/**
 * Provides a form with two steps.
 *
 * This example demonstrates a multistep form with text input elements. We
 * extend FormBase which is the simplest form base class used in Drupal.
 *
 * @see \Drupal\Core\Form\FormBase
 */
class MultistepForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'form_api_example_multistep_form';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    if ($form_state->has('page_num') && $form_state->get('page_num') == 2) {
      return $this->fapiExamplePageTwo($form, $form_state);
    }

    $form_state->set('page_num', 1);

    $form['description'] = [
      '#type' => 'item',
      '#title' => $this->t('A basic multistep form (page 1)'),
    ];

    $form['first_name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('First Name'),
      '#description' => $this->t('Enter your first name.'),
      '#default_value' => $form_state->getValue('first_name', ''),
      '#required' => TRUE,
    ];

    $form['last_name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Last Name'),
      '#default_value' => $form_state->getValue('last_name', ''),
      '#description' => $this->t('Enter your last name.'),
    ];

    $form['birth_year'] = [
      '#type' => 'number',
      '#title' => $this->t('Birth Year'),
      '#default_value' => $form_state->getValue('birth_year', ''),
      '#description' => $this->t('Format is "YYYY" and value between 1900 and 2000'),
    ];

    // Group submit handlers in an actions element with a key of "actions" so
    // that it gets styled correctly, and so that other modules may add actions
    // to the form. This is not required, but is convention.
    $form['actions'] = [
      '#type' => 'actions',
    ];

    $form['actions']['next'] = [
      '#type' => 'submit',
      '#button_type' => 'primary',
      '#value' => $this->t('Next'),
      // Custom submission handler for page 1.
      '#submit' => ['::fapiExampleMultistepFormNextSubmit'],
      // Custom validation handler for page 1.
      '#validate' => ['::fapiExampleMultistepFormNextValidate'],
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $page_values = $form_state->get('page_values');

    $this->messenger()->addMessage($this->t('The form has been submitted. name="@first @last", year of birth=@year_of_birth', [
      '@first' => $page_values['first_name'],
      '@last' => $page_values['last_name'],
      '@year_of_birth' => $page_values['birth_year'],
    ]));

    $this->messenger()->addMessage($this->t('And the favorite color is @color', ['@color' => $form_state->getValue('color')]));
  }

  /**
   * Provides custom validation handler for page 1.
   *
   * @param array $form
   *   An associative array containing the structure of the form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   */
  public function fapiExampleMultistepFormNextValidate(array &$form, FormStateInterface $form_state) {
    $birth_year = $form_state->getValue('birth_year');

    if ($birth_year != '' && ($birth_year < 1900 || $birth_year > 2000)) {
      // Set an error for the form element with a key of "birth_year".
      $form_state->setErrorByName('birth_year', $this->t('Enter a year between 1900 and 2000.'));
    }
  }

  /**
   * Provides custom submission handler for page 1.
   *
   * @param array $form
   *   An associative array containing the structure of the form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   */
  public function fapiExampleMultistepFormNextSubmit(array &$form, FormStateInterface $form_state) {
    $form_state
      ->set('page_values', [
        // Keep only first step values to minimize stored data.
        'first_name' => $form_state->getValue('first_name'),
        'last_name' => $form_state->getValue('last_name'),
        'birth_year' => $form_state->getValue('birth_year'),
      ])
      ->set('page_num', 2)
      // Since we have logic in our buildForm() method, we have to tell the form
      // builder to rebuild the form. Otherwise, even though we set 'page_num'
      // to 2, the AJAX-rendered form will still show page 1.
      ->setRebuild(TRUE);
  }

  /**
   * Builds the second step form (page 2).
   *
   * @param array $form
   *   An associative array containing the structure of the form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   *
   * @return array
   *   The render array defining the elements of the form.
   */
  public function fapiExamplePageTwo(array &$form, FormStateInterface $form_state) {

    $form['description'] = [
      '#type' => 'item',
      '#title' => $this->t('A basic multistep form (page 2)'),
    ];

    $form['color'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Favorite color'),
      '#required' => TRUE,
      '#default_value' => $form_state->getValue('color', ''),
    ];

    $form['actions'] = [
      '#type' => 'actions',
    ];

    $form['actions']['back'] = [
      '#type' => 'submit',
      '#value' => $this->t('Back'),
      // Custom submission handler for 'Back' button.
      '#submit' => ['::fapiExamplePageTwoBack'],
      // We won't bother validating the required 'color' field, since they
      // have to come back to this page to submit anyway.
      '#limit_validation_errors' => [],
    ];

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

    return $form;
  }

  /**
   * Provides custom submission handler for 'Back' button (page 2).
   *
   * @param array $form
   *   An associative array containing the structure of the form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current state of the form.
   */
  public function fapiExamplePageTwoBack(array &$form, FormStateInterface $form_state) {
    $form_state
      // Restore values for the first step.
      ->setValues($form_state->get('page_values'))
      ->set('page_num', 1)
      // Since we have logic in our buildForm() method, we have to tell the form
      // builder to rebuild the form. Otherwise, even though we set 'page_num'
      // to 1, the AJAX-rendered form will still show page 2.
      ->setRebuild(TRUE);
  }

}

How it works

buildForm() as a page dispatcher

if ($form_state->has('page_num') && $form_state->get('page_num') == 2) {
  return $this->fapiExamplePageTwo($form, $form_state);
}
$form_state->set('page_num', 1);

buildForm() runs again every single time the form rebuilds — including right after a submission. Rather than trying to hide and show different HTML blocks in one giant form, this class uses $form_state as a tiny state machine: a stored page_num value decides which page-building method actually runs. On the very first visit, page_num doesn't exist yet, so has() returns false, the condition is skipped, and page 1 gets built. $form_state->set('page_num', 1) immediately afterward makes sure the value always exists from that point on.

The Next button: its own validate and submit handlers

$form['actions']['next'] = [
  '#type' => 'submit',
  '#button_type' => 'primary',
  '#value' => $this->t('Next'),
  '#submit' => ['::fapiExampleMultistepFormNextSubmit'],
  '#validate' => ['::fapiExampleMultistepFormNextValidate'],
];

This is the mechanism that makes everything else possible. Normally, clicking any submit button on a form runs the class's validateForm() then its submitForm(). But an individual button can override that with its own #validate and #submit arrays — the :: prefix just means "a method on this same form class." So clicking Next runs fapiExampleMultistepFormNextValidate() and fapiExampleMultistepFormNextSubmit() instead of the class-level methods — completely separate logic from what the final Submit button on page 2 will trigger.

Validating just this step

public function fapiExampleMultistepFormNextValidate(array &$form, FormStateInterface $form_state) {
  $birth_year = $form_state->getValue('birth_year');
  if ($birth_year != '' && ($birth_year < 1900 || $birth_year > 2000)) {
    $form_state->setErrorByName('birth_year', $this->t('Enter a year between 1900 and 2000.'));
  }
}

Notice the check only fires if ($birth_year != '') — the field isn't #required, so an empty value is fine, but if something was entered, it has to be a sane year. This is exactly the same setErrorByName() pattern from the Form Validation lesson, just scoped to one page's fields instead of the whole form.

The moment that actually advances the page

public function fapiExampleMultistepFormNextSubmit(array &$form, FormStateInterface $form_state) {
  $form_state
    ->set('page_values', [
      'first_name' => $form_state->getValue('first_name'),
      'last_name' => $form_state->getValue('last_name'),
      'birth_year' => $form_state->getValue('birth_year'),
    ])
    ->set('page_num', 2)
    ->setRebuild(TRUE);
}

Three things happen here, chained together:

  1. The page 1 values get copied into a named storage slot, page_values. This step is easy to overlook but absolutely essential: $form_state->getValues() only reflects whatever fields are on the current page after a rebuild — page 1's fields won't exist anymore once page 2 renders. Without this explicit copy, the visitor's name and birth year would simply vanish.
  2. page_num advances to 2 — the state machine flag buildForm() checks at the top.
  3. setRebuild(TRUE) is the actual trigger for staying on the form. Without it, Drupal would treat this as a completed submission and redirect away — page_num would be 2 in storage, but nobody would ever see page 2 rendered.
The single most important line in this whole file is setRebuild(TRUE). Forget it, and a multi-step form silently breaks — Drupal finishes the submission cycle instead of showing your next page, no error, just the wrong page (or no page).

Page 1 in action

Here's page 1, filled in and ready for the Next button:

Page 1 of the multistep form filled in with first name, last name, and birth year

Page 2 and the Back button

Page 2 is built by a completely separate method, fapiExamplePageTwo(), called directly from inside buildForm() rather than being its own route:

$form['actions']['back'] = [
  '#type' => 'submit',
  '#value' => $this->t('Back'),
  '#submit' => ['::fapiExamplePageTwoBack'],
  '#limit_validation_errors' => [],
];

The color field on this page is #required — but the Back button sets #limit_validation_errors to an empty array, which tells the Form API to skip all validation for this specific button click. That's a deliberate UX choice: forcing someone to fill in a required field before letting them navigate backward would be actively hostile. Since they have to pass through this page again to actually submit, skipping validation here is completely safe.

public function fapiExamplePageTwoBack(array &$form, FormStateInterface $form_state) {
  $form_state
    ->setValues($form_state->get('page_values'))
    ->set('page_num', 1)
    ->setRebuild(TRUE);
}

setValues() restores the entire page 1 values array from that page_values storage slot — which is exactly what makes the #default_value on the page 1 fields ($form_state->getValue('first_name', '')) show the visitor's original answers instead of a blank form when they click Back.

The real submit — reading data from two different places

public function submitForm(array &$form, FormStateInterface $form_state) {
  $page_values = $form_state->get('page_values');

  $this->messenger()->addMessage($this->t('The form has been submitted. name="@first @last", year of birth=@year_of_birth', [
    '@first' => $page_values['first_name'],
    '@last' => $page_values['last_name'],
    '@year_of_birth' => $page_values['birth_year'],
  ]));

  $this->messenger()->addMessage($this->t('And the favorite color is @color', ['@color' => $form_state->getValue('color')]));
}

This only runs when the page 2 Submit button is clicked (it has no #submit override, so it falls through to the class-level submitForm()). Notice the two different ways data gets read here: the name and birth year come from the page_values storage slot saved back on page 1, while the color comes straight from $form_state->getValue('color') because that field is actually present on the page currently being submitted.

Page 2 and the final result

Here's page 2 after clicking Next from page 1:

Page 2 of the multistep form showing the Favorite color field and Back/Submit buttons

Quick check: if you click Back on page 2 without ever having entered a favorite color, does anything stop you? No — #limit_validation_errors => [] deliberately skips validation for the Back button, precisely so this can't block navigation.

Key takeaways

  • $form_state->set('key', $value) and ->get('key') are how you store and retrieve arbitrary data across rebuilds — the foundation of every multi-step form in Drupal.
  • $form_state->setRebuild(TRUE) inside a submit handler is what keeps the form alive for another step instead of ending the submission cycle — forgetting it is the most common multi-step form bug.
  • A button's own #submit and #validate arrays completely override the class-level submitForm()/validateForm() for that specific click, letting each step have independent logic.
  • Earlier-page values must be explicitly copied into $form_state storage before moving on — getValues() only reflects whatever fields exist on the current page after a rebuild.
  • #limit_validation_errors => [] on a button skips validation entirely for that click — the standard way to let a "Back" or "Cancel" button bypass required-field checks.

Coming up next

Multi-step forms move data forward across full page reloads. But what if you want a form to update itself instantly, without any page reload at all — a dropdown that changes another field the moment you pick an option? That's AJAX in forms, and it's the last stop in this topic before we move on to AJAX more broadly.