Drupal 7 multistep forms

I like building forms. So much so that I've even been teased about it. Despite that I want to share how multistep forms have changed for Drupal 7 and to expand on how you can use variable functions to achieve cleaner and easier form step logic, including easily moving backwards in forms. Understanding multistep in Drupal 7 was prompted by my need to create easy forms for an internal GVS project that will hopefully launch soon.

Multistep in Drupal 7

In Drupal 6 to carry data back to your form builder you set the storage key of $form_state in your submit handler. In Drupal 7, upon return to your builder after submission, you carry data over by keeping the Form API from pulling the form array out of cache*. You do so by setting $form_state['rebuild'] to TRUE in your validate or submit handlers. Another change is the first argument of your builder must be $form because of changes to drupal_get_form(). &$form_state is now your second argument to your form builder.

Update: 'rebuild' existed in Drupal 6 (thanks Wim) but now seems to be required for multistep to work in Drupal 7.

Drupal 7:

<?php
// Form builder definition.
function my_form($form, &$form_state) {...}

// Form submit handler.
function my_form_builder_submit($form, &$form_state) {
 
// Trigger multistep.
 
$form_state['rebuild'] = TRUE;
 
// Store values that will be available when we return to the definition.
 
$form_state['storage']['values'] = $values;
}
?>

Let's look at a example:

<?php
// multistep_simple, our form builder function.
function multistep_simple($form, &$form_state) {
 
// Check if storage contains a value. A value is set only after the form is submitted and 'rebuild' is set to TRUE.
 
if (!empty($form_state['storage']['myvalue'])) {
   
// Display a message with the submitted value.
   
drupal_set_message(t("You submitted: @name", array('@name' => $form_state['storage']['myvalue'])));
  }
 
$form['name'] = array(
   
'#type' => 'textfield',
   
'#title' => t('Name'),
   
'#description' => t('Enter your name'),
   
'#required' => TRUE,
  );
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Submit'),
  );
  return
$form;
}

// Our submit handler for multistep_simple.
function multistep_simple_submit($form, &$form_state) {
 
// Tell FAPI to rebuild.
 
$form_state['rebuild'] = TRUE;
 
// Store submitted value.
 
$form_state['storage']['myvalue'] = $form_state['values']['name'];
}
?>

We could of course set the message in the submit handler but I hope the example helps convey the two points of the definition arguments and setting rebuild in the submit handler.

Multistep with variable functions and FAPI handler convention

Using variable functions is a great way to make readable form step logic. And, if you combine this method with Drupal's form handler definition style and you can easily make advanced forms.

Update: This is by no means a method that exists only in Drupal 7. This is just a process I've written about before for making form-step logic easier to code and extend. What I'm writing about here is an expansion on that process.

The components:

  1. Store step names (function definitions) in $form_state['storage']
  2. Return the current step's form array in the main FAPI builder
  3. Invoke validate and submit handlers for a individual step by appending '_validate' or '_submit' to the step definition

Let me start with the form builder and submit handler for illustration. The form ID known to FAPI will be multistep_form and multistep_form_submit the submit handler. The builder uses functions defined in storage to know which step to return and the submit handler uses FAPI convention to call a individual step's validate or submit.

<?php
// Primary form builder.
function multistep_form($form, &$form_state) {
 
// Initialize.
 
if ($form_state['rebuild']) {
   
// Don't hang on to submitted data in form state input.
   
$form_state['input'] = array();
  }
  if (empty(
$form_state['storage'])) {
   
// No step has been set so start with the first.
   
$form_state['storage'] = array(
     
'step' => 'multistep_form_start',
    );
  }

 

// Return the form for the current step.
 
$function = $form_state['storage']['step'];
 
$form = $function($form, $form_state);
  return
$form;
}

// Primary submit handler.
function multistep_form_submit($form, &$form_state) {
 
$values = $form_state['values'];
 
// Check if we're moving back or forward in the form.
 
if (isset($values['back']) && $values['op'] == $values['back']) {
   
// Code for moving in reverse left out for now...
 
}
  else {
   
// Record the current step.
   
$step = $form_state['storage']['step'];
   
$form_state['storage']['steps'][] = $step;
   
// Call step submit handler if it exists.
   
if (function_exists($step . '_submit')) {
     
$function = $step . '_submit';
     
// Current step's submit handler will set the next step.
     
$function($form, $form_state);
    }
  }
  return;
}
?>

Step submit handlers specify the next step to use and step builders can skip steps.

Here's a diagram depicting the flow of standard FAPI and how the builder and handlers call off to a individual step.

Here's an example individual step and it's submit handler:

<?php
function multistep_form_start($form, &$form_state) {
 
$form['musician'] = array(
   
'#type' => 'radios',
   
'#title' => t('Choose a musician'),
   
'#options' => array(
     
'davis' => t('Miles Davis'),
     
'coltrane' => t('John Coltrane'),
     
'corea' => t('Chick Corea'),
     
'brubeck' => t('Dave Brubeck'),
     
'other' => t('Other')
    ),
   
'#default_value' => isset($form_state['storage']['musician']) ? $form_state['storage']['musician'] : NULL,
  );
 
// Next button.
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Next'),
  );
  return
$form;
}

function

multistep_form_start_submit($form, &$form_state) {
 
// Trigger multistep, there are more steps.
 
$form_state['rebuild'] = TRUE;
 
$values = $form_state['values'];
  if (isset(
$values['back']) && $values['op'] == $values['back']) {
   
// User is moving back from this form, clear our storage.
    // [...]
 
}
  else if (
$form_state['values']['musician'] == 'other') {
   
// User chose 'other' so inject an intermediary step.
   
$form_state['storage']['musician'] = NULL; // Clear out because of our define musician step uses key 'musician' as well.
   
$form_state['storage']['step'] = 'multistep_form_define_musician';
  }
  else {
   
// We could do something with the values here, like saving etc...
    // Hold on to value.
   
$form_state['storage']['musician'] = $form_state['values']['musician'];
   
// Set the next step.
   
$form_state['storage']['step'] = 'multistep_form_songs';
  }
}
?>

The above example defines a single step and sets us up to move through the chain of several. Let's look at the next step and define how we could move backwards. We'll redefine the primary submit handler now.

Update: The form property #limit_validation_errors and a element submit property are required on back buttons should any step have anything that would trigger validation.

<?php
// Primary submit handler.
function multistep_form_submit($form, &$form_state) {
 
$values = $form_state['values'];
  if (isset(
$values['back']) && $values['op'] == $values['back']) {
   
// Moving back in form.
   
$step = $form_state['storage']['step'];
   
// Call current step submit handler if it exists to unset step form data.
   
if (function_exists($step . '_submit')) {
     
$function = $step . '_submit';
     
$function($form, $form_state);
    }
   
// Remove the last saved step so we use it next.
   
$last_step = array_pop($form_state['storage']['steps']);
   
$form_state['storage']['step'] = $last_step;
  }
  else {
   
// Record step.
   
$step = $form_state['storage']['step'];
   
$form_state['storage']['steps'][] = $step;
   
// Call step submit handler if it exists.
   
if (function_exists($step . '_submit')) {
     
$function = $step . '_submit';
     
$function($form, $form_state);
    }
  }
  return;
}

function

multistep_form_songs($form, &$form_state) {
 
$form['song'] = array(
   
'#type' => 'textfield',
   
'#title' => t('Favorite recording?'),
   
'#default_value' => isset($form_state['storage']['song']) ? $form_state['storage']['song'] : NULL,
  );
 
$form['unknown'] = array(
   
'#type' => 'checkbox',
   
'#title' => t("I don't know"),
  );
 
$form['back'] = array(
   
'#type' => 'submit',
   
'#value' => t('Back'),
  );
 
$form['submit'] = array(
   
'#type' => 'submit',
   
'#value' => t('Next'),
  );
 
  return
$form;
}

function

multistep_form_songs_submit($form, &$form_state) {
 
$values = $form_state['values'];
 
$form_state['rebuild'] = TRUE;
  if (isset(
$values['back']) && $values['op'] == $values['back']) {
   
// User is moving back from this form, clear our storage.
   
$form_state['storage']['song'] = NULL;
  }
  else if (
$values['unknown']) {
   
// Skip to confirm step.
   
$form_state['storage']['step'] = 'multistep_form_confirm';
  }
  else {
   
$form_state['storage']['song'] = $form_state['values']['song'];
   
// Set the next step.
   
$form_state['storage']['step'] = 'multistep_form_heard';
  }
}
?>

In our second step form builder (multistep_form_songs) we provide a back button. The primary submit handler allows the current step to remove stored data (to avoid a previously set default value when we return) and then removes the most recent step so when returned to the primary builder the previous step is used.

A full multistep example is provided in the attachment. You'll need to rename the files excluding the '.txt' extension to use. Some assumptions are made, of course, so adjustment for your use cases is required.

P.S.
If you didn't catch it, in Drupal 7 you are not limited to 'storage' for carrying data across requests. It's used in most of the examples, but you'll see that it's not required.
*I'm not clear on some core implementation specifics regarding the form cache so if you know please share!

Tags: