Multi-step Forms in Drupal 6 using variable functions

Apr 7 2009

I recently had to write a multi-step form in Drupal 6. Of course, I turned to documentation to see how others are doing it. Pro Drupal Development offers the basics, so do the 5 to 6 upgrade notes, and others. I felt that many approaches suffered from design flaws that made the code cumbersome to manage beyond a couple steps. I set out to develop a multi-step form method with the following goals:

  • One form builder with nested conditional statements is difficult to manage, each step should be its own form array function
  • Steps shouldn't be numbered, e.g. to move to the next step don't $form_state['storage']['step']++
  • Each step should be able to have its own validate and submit handlers
  • Steps should be form alterable

Skip to the attachments for full code example

As far as Form API knows, there is one form builder and thus one validation and submit function. The key is, each piece (builder, validate, and submit function) directs to sub-functions that perform during the appropriate step. Value elements are heavily used to control flow by informing the system what the next step is and for handling step validation and submit.

Let's look at some pseudo-code to explain the main idea of what I'm doing.

We get started with drupal_get_form('main_builder')

function main_builder(form_array) {
  Check if we've set our next step
 
  If we have call that step's function and
  return it's Form array
 
  If we don't have a step than we're at the
  beginning of our form
  Call and return first_step()
}

function first_step(form_array) {
  Build the form elements we want the user to enter

  Define what the next step from this one is
  form_array['next_step'] = 'a_single_step';
  
  return form_array;
}

function a_single_step(form_array) {
  Build the form elements we want the user to enter
 
  Define what the next step from this one is
  form_array['next_step'] = 'next_step';

  return form_array;
}

function main_builder_submit(form_array) {
  Store submitted form values

  Set next step
}

The advantage here is each step knows the next step and each step is contained within its own function, allowing for easy modification. The main form builder function dispatches to each individual step to build its own form array. The form submit handler stores submitted values in form storage per usual.

Let's look at some Drupal code now.

<?php
// We call drupal_get_form('multistep_form')
// elsewhere, such as an implementation of hook_menu().

function multistepform_form($form_state) {
  if (!empty(
$form_state['storage']['step'])) {
   
$function = $form_state['storage']['step'];
    return
$function($form_state);
  }
  else {
    return
_multistepform_form_start(); 
  }
}

function
_multistepform_form_start() {
 
$form['name'] = array(
   
'#type' => 'textfield',
   
'#title' => t('Name'),
   
'#required' => TRUE,
  );

 
$form['continue'] = array(
   
'#type' => 'submit',
   
'#value' => 'Continue',
  );
 
// Our special value elements.
 
$form['this_step'] = array(
   
'#type' => 'value',
   
'#value' => 'start',
  );
 
$form['step_next'] = array(
   
'#type' => 'value',
   
'#value' => '_multistepform_form_food',
  );
  return
$form;
}

function
_multistepform_form_food($form_state) {
 
$form['food'] = array(
   
'#type' => 'textfield',
   
'#title' => t('Food'),
   
'#required' => TRUE,
  );
 
 
$form['continue'] = array(
   
'#type' => 'submit',
   
'#value' => 'Continue',
  );
 
$form['this_step'] = array(
   
'#type' => 'value',
   
'#value' => 'food',
  );
  return
$form;
}

function
multistepform_form_submit($form, &$form_state) {
  if (empty(
$form_state['storage'])) {
   
$form_state['storage'] = array();
   
$form_state['storage']['values'] = array();
  }
 
// Store submitted form values
 
$this_step = $form_state['values']['this_step'];
 
$form_state['storage']['values'][$this_step] = $form_state['values'];

 
// Set up next step.

 
if (!empty($form_state['values']['step_next'])) {
   
$form_state['storage']['step'] = $form_state['values']['step_next'];
  }
  else {
   
// Form complete!
   
drupal_set_message(t('Complete.'));
  }
}
?>

See the full example in the attachments section

As you can see, this method uses special form value elements to define the flow. The main builder uses variable functions to delegate which step it is. The second step, _multistepform_form_food() does not set a 'step_next' value and so on submission we don't set a step that will be called when we return to the main builder. I use the 'this_step' value to namespace submitted values in form storage. Other than displaying the message "Complete!" I am not actually doing anything with the final, collected values yet.

The same delegation method using variable functions that the main form builder does can be applied to validate and submit handlers by creating value elements 'step_validate' and 'step_submit' with function names as the value in step form builders and the following main validate and submit addition:

<?php
function multistepform_form_validate($form, &$form_state) {
  if (!empty(
$form_state['values']['step_validate'])) {
   
$function = $form_state['values']['step_validate'];
   
$function($form, $form_state);
  }
}
?>

And this code in multistepform_form_submit().

<?php
 
if (!empty($form_state['values']['step_submit'])) {
   
$function = $form_state['values']['step_submit'];
   
$function($form, $form_state);
  }
?>

Custom validation functions can form_set_error() normally. By specifying a step submit, our submit function can alter the form flow, skipping steps.

<?php
function _multistepform_form_like_music($form_state) {
 
$form['like_music'] = array(
   
'#type' => 'radios',
   
'#title' => t('Do you like music?'),
   
'#options' => array(
     
0 => 'No',
     
1 => 'Yes',
    ),
   
'#required' => TRUE,
  );
 
 
$form['continue'] = array(
   
'#type' => 'submit',
   
'#value' => 'Continue',
  );
 
$form['this_step'] = array(
   
'#type' => 'value',
   
'#value' => 'like_music',
  );
 
// New value, 'step_submit'.
 
$form['step_submit'] = array(
   
'#type' => 'value',
   
'#value' => '_multistepform_form_my_submit',
  );
 
$form['step_next'] = array(
   
'#type' => 'value',
   
'#value' => '_multistepform_form_music',
  );
  return
$form;
}

function
_multistepform_form_my_submit($form, &$form_state) {
  if (
$form_state['values']['like_music'] == '0') {
   
// If the user doesn't like music, well don't ask them anything more about it!
   
$form_state['values']['step_next'] = '_multistepform_form_final';
  }
}
?>

In this example if the user says (s)he doesn't like music (Who would choose that, really?) then instead of seeing the step _multistepform_form_music() (s)he gets sent to _multistepform_form_final().

I touched on the 'this_step' value element earlier, but it plays the part of identifying individual steps, allowing each step to be form alterable. Your implementation of hook_form_alter() could look for the multi-step form_id and if $form['this_step'] matches what you're looking for. hook_form() may offer a solution, but I'm not certain it's better or easier than using an additional value element.

After developing this method I was informed it is like Chaos tool suite's wizard. Other multi-step form approaches exist such as Pageroute, an object-based approach to steps of a flow.

The important pieces to use are variable functions and passing the $form_state array around by reference. For further exploration on this method I plan to see if it could be used to move backwards in a form, returning to previous steps. And the special value elements here are just a convention but I could see them being defined by hook_element().

Take a look at the attached .module for a full example spanning more than two steps. The code implements custom validation and a custom submit handler to alter form flow. If you would like to demo the code you'll need a multistepform.info file. Further directions for module development is in the Drupal handbooks.

AttachmentSize
multistepform.module.txt4.94 KB

Comments

chx writes:

I would store the function name in $form['#foo'] instead of a #type value but other than that, it's a great article.

Ben Jeavons writes:

Thanks Károly, that's a good point.

Jim writes:

Dan DeGeest writes:

Great article. I employ a similar "delegate" function pattern for other hooks which allows me to keep the code nicely segregated for each bit of discrete function.

http://www.imedstudios.com/labs/node/17

Ben Jeavons writes:

Definitely, it follows what Drupal does with its hook system. Thanks for sharing!

Leandro Ardissone writes:

It would be great if someone prepare a module or extend webforms to allow multi-step forms.

Ben Jeavons writes:

Hi Leandro, webform provides the 'pagebreak' component which makes the form multi-step (see http://drupal.org/handbook/modules/webform). I don't believe you can get complex with the steps, but it's a start.

greggles writes:

Webform conditional fields patch: http://drupal.org/node/254728

Yay netaustin/Economist for the work on it ;)

sirkitree writes:

Thanks for this! I'm using this technique in an upcoming module admin interface, though I added the ability of a back button as well.

Anonymous writes:

Could you provide me the snippet where you used the back button. It would be really helpful

Anonymous writes:

Hi Ben - Is it possible to have a template to
theme each page of the form?

Ken writes:

I've noticed a problem when validating multi-step forms. When any step other than the first one fails a validation, any fields that had been filled in on that step get wiped out. For instance, if you modify your code to put:

$form['other2'] = array(
'#type' => 'checkboxes',
'#options' => array('first' => 'First Box',
'second' => 'Second Box',
'third' => 'Third Box'),
'#title' => t('Check some'),
);

in your second step and you check some of the boxes, those boxes are not checked anymore if the step fails validation.

Is there a solution to this problem? I've been banging my head against the wall trying to figure it out for a few weeks...

Other than that, your code is very nice and easy to follow...

Ken

RoloDMonkey writes:

I just ran into the exact same problem. Has anyone figured this out yet?

RoloDMonkey writes:

Okay, I will answer my own question. When validation failed, I was overriding the values from the previous page. Let me walk you through it:

Let's say, that page one has an input for "color". When page one was submitted, page two would store the value:

$form_state['storage']['color'] = $form_state['values']['color'];

Next, I would submit page two. However, page two would fail validation and reload. At this point there is no value in $form_state['values']['color'], so the existing $form_state['storage']['color'] is overwritten with NULL!

To solve this, first I wrote a function like this:

function wizard_store_value($value, &$form_state) {
if (isset($form_state['values'][$value_name])) {
$form_state['storage'][$value_name] = $form_state['values'][$value_name];
}
}

And then I changed the line above to:

wizard_store_value("color", $form_state);

Problem solved.

Anonymous writes:

I am having this same problem, but I don't get how to implement the code piece you put here. I don't have a line like your:
$form_state['storage']['color'] = $form_state['values']['color'];
I am just setting the form error, and it clears the form. Any direction here?

Stephen writes:

Correct me if I'm wrong, but I think you may need to set a "#default_value" for the field items in order to make them stick, at least with these multi-page forms.

A single page form seems to remember its values if it doesn't validate, but for this multiform tactic, it seems you need to be checking the $form_state['values'] array all the time...

ex.
$form['myField'] = array(
'#type' => 'textfield',
'#title' => 'My Field',
'#default_value' => isset($form_state['values']['myField']) ? $form_state['values']['myField'] : '',
);

At least that's what I had to do. I don't know enough of the inner workings of Drupal to figure out why a single page form works and a multipage form doesn't. :P

Stephen writes:

If it's any help to anybody, to stop your fields from disappearing after a failed validation: at the bottom of my form definition functions, I do a for loop that looks kinda like this:

<?php
// Loop through all the form elements under a particular fieldset
foreach ($myform['FIELDSET'] as $key => &$value) {
   
// If it's an array, then it's a form item
   
if (is_array($value)) {
     
// Set its default value to the value we have in the form state
     
$myform['FIELDSET'][''.$key]['#default_value'] = $form_state['values'][''.$key];
    }
}
?>

You'll have to do something like that for each of your fieldsets in your form. (you might be able to shorten that code so it does them all in one loop, doing every fieldset, but I couldn't get it to work)

---

Also, I couldn't seem to get the $form['#redirect'] to work at all, because I think the author forgot a step in the sample code.

If your code, you have the following comment:

<?php
// Set $form['#redirect'] to not return to the first step.
?>

You can set $form['#redirect'] in the final step's form constructor, or perhaps better, set $form_state['redirect'] right after that comment, since you're doing clean up and finalize things at that point anyways.

The important part you're missing though, is that it seems like the Form API (at least in Drupal 6.13) will only fire off a redirect if $form_state['storage'] is empty. (line 443 in /includes/form.inc)

If you want to do a redirect, the code should look like this:

<?php
// Set $form['#redirect'] to not return to the first step.
/* new */
$form_state['storage'] = NULL; // destroy the storage array
$form_state['redirect'] = ''; // define a url, or leave this out if you defined $form['#redirect'] in your final constructor
?>

Hope that helps somebody!

Anonymous writes:

Looks like what I was looking for.
Question - I am trying to translate my currently working multistep form into your method. In my form, at the top of the form creating step I have a bunch of arrays that I use in multiple places in my form eg for radio button values like
$yes_no_options = array(
'Y'=> t('Yes'),
'N'=> t('No'),
'unsure'=> t('Not sure')
);

where would I declare/place these such that I could use them on any "page" of the multistep form

thanks
Kath

RoloDMonkey writes:

Just put your code into a function:

<?php
function mymodule_ynu_options() {
  return array(
   
'Y'=> t('Yes'),
   
'N'=> t('No'),
   
'unsure'=> t('Not sure')
  );
}
?>

This is very useful if you are generating a more complex option list dynamically, either from the values that were submitted or from a database query.

Your code for storing and validating the submission can use the same function.

Anonymous writes:

Is there an easier way to implement a back button feature than pullling the items out of storage and repopulating the form manually?

Kath writes:

I have this working great. Thanks so much. Modularizing it like this really helps me.

Two questions

1. When using IE(6), if I hit the browser 'back button' I get a page expired button. And then if I try to reload page it won't let me unless I close my browser. Any ideas.

2. Is it possible to direct a user to a certain page? for example on page two have a button for go to page 3 or go to page 4?

As always, once I have something working I always get more complicated.

Thanks

Rick writes:

Any progress on this Kath? -- I'm hitting the same thing. Thanks!

Kath writes:

sorry - nope. I decided to use http://thedrupalblog.com/creating-multip... instead.

Anonymous writes:

Thanks, very helpful.