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).
Reference: Architecture Overview
Multi-Step Requirements:
1. Enable caching: $form_state->setCached(TRUE) [REQUIRED]
2. Store current step: $form_state->set('step', $step_number)
3. Conditional buildForm() based on step
4. Navigation buttons: Next, Previous, Submit
5. Final step processes all collected data
Why Caching Required:
Form must persist across page requests
form_build_id links requests to cached form
Without cache: FormState storage lost between steps
Cache location: database cache_form table
Pattern: Implementation
buildForm() Structure:
public function buildForm(array $form, FormStateInterface $form_state) {
// Enable caching (REQUIRED for multi-step)
$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);
}
}
Step Builder Methods:
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']], // Only validate step1
];
return $form;
}
Pattern: Navigation Handlers
Next Button:
public function nextSubmit(array &$form, FormStateInterface $form_state) {
// Save 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 for next step
$form_state->setRebuild(TRUE);
}
Previous Button:
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);
}
// In buildForm, add previous button:
$form['actions']['previous'] = [
'#type' => 'submit',
'#value' => $this->t('Previous'),
'#submit' => ['::previousSubmit'],
'#limit_validation_errors' => [], // No validation for going back
];
Final Submit:
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
// ... save to database, create entities, etc.
// Redirect
$form_state->setRedirect('route.success');
}
Pattern: Data Persistence
Store Step Data:
$form_state->set('step1_data', $data); // Persist across steps
$form_state->set('total_steps', 3); // Configuration
$form_state->set('entity', $entity); // Working entity
Pre-populate from Previous Steps:
$previous_data = $form_state->get('step1_data');
$form['field']['#default_value'] = $previous_data['field'] ?? '';
Pattern: Progress Indicators
Simple Counter:
$step = $form_state->get('step');
$total = $form_state->get('total_steps') ?? 3;
$form['progress'] = [
'#markup' => $this->t('Step @current of @total', [
'@current' => $step,
'@total' => $total,
]),
];
Progress Bar (Render Array):
$form['progress'] = [
'#theme' => 'progress_bar',
'#percent' => ($step / $total) * 100,
'#message' => $this->t('Step @current of @total', [
'@current' => $step,
'@total' => $total,
]),
];
Pattern: Common Patterns
Conditional Steps:
// In nextSubmit:
if ($form_state->getValue(['step1', 'skip_step2'])) {
$form_state->set('step', 3); // Skip to step 3
}
else {
$form_state->set('step', 2);
}
Step Validation:
public function validateForm(array &$form, FormStateInterface $form_state) {
$step = $form_state->get('step');
// Step-specific validation
switch ($step) {
case 1:
$this->validateStep1($form, $form_state);
break;
case 2:
$this->validateStep2($form, $form_state);
break;
}
}
Common Mistakes
- Forgetting
setCached(TRUE)(data loss between steps) - Not using
#limit_validation_errorson Next/Previous - Not calling
setRebuild(TRUE)in navigation handlers - Storing data in local variables instead of FormState
- Not pre-populating fields when returning to previous step
See Also
- Form State Methods (dedicated section)
- Partial Validation (dedicated section)
- Form Caching Strategy
- Tutorial: SitePoint: Multi-step Forms