Skip to content

Multi-Step Form Workflows

When to Use

Use multi-step AJAX forms for wizard-style workflows where users navigate sequential steps without page reloads. Use standard forms for simple one-page submissions.

Decision

At this step... If... Then...
Navigation First step Hide "Previous" button
Navigation Last step Show "Submit" instead of "Next"
Data persistence Moving between steps Store values in $form_state->set('data', $values)
Validation Step requires validation Use separate submit handler with validation
Progress indication Multiple steps Add progress bar showing current step

Pattern

public function buildForm(array $form, FormStateInterface $form_state) {
  $step = $form_state->get('step') ?: 1;
  $form_state->set('step', $step);

  $form['#prefix'] = '<div id="form-wrapper">';
  $form['#suffix'] = '</div>';

  switch ($step) {
    case 1: $form['step1'] = $this->buildStep1($form_state); break;
    case 2: $form['step2'] = $this->buildStep2($form_state); break;
  }

  $form['actions']['next'] = [
    '#type' => 'submit',
    '#value' => t('Next'),
    '#submit' => ['::nextStep'],
    '#limit_validation_errors' => [],
    '#ajax' => ['callback' => '::stepCallback', 'wrapper' => 'form-wrapper'],
  ];
  return $form;
}

public function nextStep(array &$form, FormStateInterface $form_state) {
  $step = $form_state->get('step');
  $form_state->set('step', $step + 1);
  $form_state->setRebuild();  // CRITICAL: rebuilds form
}

public function stepCallback(array &$form, FormStateInterface $form_state) {
  return $form;  // Return whole form to update all content
}

Reference: core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormsTestLazyLoadForm.php

Common Mistakes

  • Wrong: Forgetting $form_state->setRebuild()Right: Without it, form submits instead of rebuilding
  • Wrong: Not wrapping entire form → Right: Without a wrapper around the whole form, navigation buttons don't update
  • Wrong: Validating on navigation buttons → Right: Add #limit_validation_errors => [] to Previous/Next buttons
  • Wrong: Storing data in private properties → Right: Store in $form_state — properties are lost on rebuild
  • Wrong: Not conditionally showing Previous/Submit buttons → Right: No Previous on step 1, show Submit only on final step

See Also