Skip to content

Multi-Step Form Pattern

When to Use

Use multi-step forms for complex workflows requiring user input across multiple pages. Always enable caching with setCached(TRUE).

Decision

Requirement Implementation Why
Multi-step wizard FormBase + setCached(TRUE) State persistence required
Progressive data collection Multi-step pattern Better UX than long single form
Conditional step branching Custom step logic Skip irrelevant steps
Simple single form Standard FormBase No caching needed

Pattern

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

class MultiStepForm extends FormBase {
  public function getFormId() {
    return 'multistep_form';
  }

  public function buildForm(array $form, FormStateInterface $form_state) {
    // REQUIRED: Enable caching
    $form_state->setCached(TRUE);

    // Get or initialize step
    $step = $form_state->get('step') ?? 1;
    $form_state->set('step', $step);

    // Build step-specific form
    switch ($step) {
      case 1:
        return $this->buildStep1($form, $form_state);
      case 2:
        return $this->buildStep2($form, $form_state);
      case 3:
        return $this->buildStep3($form, $form_state);
    }
  }

  protected function buildStep1(array $form, FormStateInterface $form_state) {
    $form['step1'] = [
      '#type' => 'container',
      '#tree' => TRUE,
    ];

    $form['step1']['name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Name'),
      '#required' => TRUE,
    ];

    $form['actions']['next'] = [
      '#type' => 'submit',
      '#value' => $this->t('Next'),
      '#submit' => ['::nextSubmit'],
      '#limit_validation_errors' => [['step1']], // Validate step1 only
    ];

    return $form;
  }

  public function nextSubmit(array &$form, FormStateInterface $form_state) {
    // Save current step data
    $step = $form_state->get('step');
    $step_data = $form_state->getValue('step' . $step);
    $form_state->set('step' . $step . '_data', $step_data);

    // Advance step
    $form_state->set('step', $step + 1);

    // Rebuild form
    $form_state->setRebuild(TRUE);
  }

  public function previousSubmit(array &$form, FormStateInterface $form_state) {
    // Go back one step
    $step = $form_state->get('step');
    $form_state->set('step', $step - 1);

    // Rebuild form
    $form_state->setRebuild(TRUE);
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    // Collect all step data
    $step1_data = $form_state->get('step1_data');
    $step2_data = $form_state->get('step2_data');
    $step3_data = $form_state->getValue('step3');

    // Process complete submission
    $this->processData($step1_data, $step2_data, $step3_data);

    // Redirect
    $form_state->setRedirect('route.success');
  }
}

Next Button: - Save current step data via set() - Increment step counter - Call setRebuild(TRUE) - Use #limit_validation_errors to validate current step only

Previous Button: - Decrement step counter - Call setRebuild(TRUE) - Use #limit_validation_errors => [] (no validation)

Final Submit: - Collect data from all steps - Process complete submission - Redirect to success page

Data Persistence

// Store step data (persists across rebuilds)
$form_state->set('step1_data', $data);
$form_state->set('current_step', 2);

// Retrieve later
$previous_data = $form_state->get('step1_data');
$step = $form_state->get('current_step');

// Pre-populate from previous steps
$previous_data = $form_state->get('step1_data');
$form['field']['#default_value'] = $previous_data['field'] ?? '';

Progress Indicator

// Simple counter
$step = $form_state->get('step');
$total = 3;

$form['progress'] = [
  '#markup' => $this->t('Step @current of @total', [
    '@current' => $step,
    '@total' => $total,
  ]),
];

Common Mistakes

  • Wrong: No setCached(TRUE) → Right: REQUIRED for multi-step (data lost otherwise)
  • Wrong: No #limit_validation_errors on Next/Previous → Right: Validate only current step
  • Wrong: No setRebuild(TRUE) in navigation → Right: Required to rebuild form
  • Wrong: Local variables for step data → Right: Use FormState storage
  • Wrong: No pre-population on back → Right: Restore previous values

See Also