Posted on 40 Comments

Mehrseitige Formulare mittels Zend Framework umsetzenImplementing multi-page forms using the Zend Framework

Unfortunately, the Zend Framework (ZF) version 1.x currently does not provide a standard way or class to implement multi-page forms based on Zend_Form. Due to the fact that this is a very common use case I will try to explain a working solution to overcome this problem.

Basically, the goal is to provide means to connect multiple sub-forms (Zend_Form_SubForm) together that can be browsed (“paged”) by users. Ideally, we want some sort of breadcrumb navigation too to enable users to jump directly to specific sub-forms, or steps. Data submitted for these steps will be kept inside a specific session namespace (Zend_Session_Namespace) to maintain states inbetween step changes.

UPDATE: Due to numerous requests I’ve decided to finally create a sample Zend project that incorporates the multi-page functionality described in this post. Please check the GitHub repository for the latest version.

Below you find the skeleton code required for multi-page form using ZF.

The Base Form

First of all, we need a basic form (Zend_Form) that incorporates our sub-forms, or steps. Since we want to reuse this base form we will create a base multi-page form class called Form_MultiPage, as shown in the following code snippet:

class Form_MultiPage extends Zend_Form {

 /**
  * Prepare a sub form for display.
  * @param  string|Zend_Form_SubForm $spec
  * @param bool? $isLastSubForm
  * @param bool? $showSubmitButton
  * @return Zend_Form_SubForm
  */
  public function prepareSubForm($spec, $isLastSubForm=false, $showSubmitButton=true) {}

 /**
  * Sets default form decorators to sub form unless custom decorators have 
  * been set previously.
  * @param  Zend_Form_SubForm $subForm
  * @return Form_MultiPage
  */
  public function setSubFormDecoratorsT(Zend_Form_SubForm $subForm) {}

 /**
  * Adds a submit button to @subForm.
  * @param  Zend_Form_SubForm $subForm
  * @param bool? $isLastSubForm
  * @param string? $continueLabel
  * @param string? $finishLabel
  * @return Zend_Form
  */
  public function addSubmitButton(Zend_Form_SubForm $subForm, $isLastSubForm=false, $continueLabel = 'Save and Continue', $finishLabel='Finish') {}

 /**
  * Adds action and method to sub form.
  * @param Zend_Form_SubForm $subForm
  * @return Zend_Form_SubForm
  */
  public function addSubFormAction(Zend_Form_SubForm $subForm) {}
}

As you can see Form_MultiPage already provides us with a limited set of functionality, most important of all prepareSubForm, which prepares the current sub-form to display.

The Concrete Base Form

Based on Form_MultiPage we then can derive the concrete base forms. For instance, let’s say we want to create a multi-page user registration form called Register_Form consisting of three steps: Account-, personal- and profile data.

class Register_Form extends Form_MultiPage {

  /**
  * Setup the sub-forms for this multi-page form.
  */
  public function init() {
    parent::init();

    $step1SubForm = new Register_Form_StepOne();
    $step2SubForm = new Register_Form_StepTwo();
    $step3SubForm = new Register_Form_StepThree();

    $this->addSubForms(array(
      'step1' => $step1SubForm,
      'step2' => $step2SubForm,
      'step3' => $step3SubForm
     ));
    }
}

Note that the sub forms Register_Form_StepXXX represent the actual forms that will be displayed to the users.

Based on Register_Form we are ready to start building the business logic for our multi-page forms.

The Controller

In order to process form requests we need a Zend_Controller that is capable of dealing with our multi-page form requests. Consequently, we introduce the Controller_MultiPage, which incorporates the basic functionality needed to handle multi-page form submissions:

class Controller_MultiPage extends Zend_Controller_Action {

    protected $_formClass;
    protected $_formClassName;
    protected $_form;
    protected $_verificationViewScript;
    protected $_namespace;
    protected $_session;

    /**
     * Determines which sub-forms should not have CSRF-protection
     * @var array 
     */
    protected $_excludeCsrfSubForms = array();

    /**
     * Checks step (subform) based on session data.
     * @param Zend_Form_SubForm $form 
     */
    protected function checkSessionStep(&$form) {}

    /**
     * Default populate function. You can override this function in your concrete
     * controller.
     * @param Zend_Form_SubForm $form
     * @param array $data
     */
    protected function populateForm($form, $data) {}

    /**
     * Custom sub form pre checks/actions.
     * @param Zend_Form_SubForm $subForm
     * @param array $rawPostData
     * @return Zend_Form_SubForm
     */
    protected function preSubFormActions($subForm, $rawPostData) {}

    /**
     * Checks whether to attach submit button.
     * @param string $formName
     * @return bool 
     */
    protected function checkAttachSubmitButton($formName) {}

    /**
     * Checks subform identified by $step.
     * @param string $step, e.g. 'step'[NUM], e.g. "step1"
     * @param Zend_Form_SubForm $subForm
     * @param array $cleanedPostData
     * @param array $rawPostData 
     * @return bool|array
     */
    protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {}

    /**
     * Returns current form, i.e. form to be displayed to user based on previous action.
     * @return Zend_Form_SubForm
     */
    public function getForm() {}

    /**
     * Get the session namespace we're using.
     * @return Zend_Session_Namespace
     */
    public function getSessionNamespace() {}

    /**
     * Returns session namespace data as associative array.
     * @return array
     */
    public function getSessionNamespaceData() {}

    /**
     * Get a list of forms already stored in the session, i.e. those steps that have been verified.
     * @return array
     */
    public function getStoredForms() {}

    /**
     * Get list of all subforms available, i.e. the "steps".
     * @return array
     */
    public function getPotentialForms() {}

    /**
     * Determines which subform was submitted.
     * @return false|Zend_Form_SubForm
     */
    public function getCurrentSubForm() {}

    /**
     * Returns the "next" sub form to display. If current request is not POST, 
     * i.e. the page was requested directly via GET the user is redirected to
     * the first sub form. If the current subform is the last potential form it
     * will be returned again, otherwise the next sub form will be returned.
     * @return Zend_Form_SubForm|false
     */
    public function getNextSubForm() {}

    /**
     * Returns sub form specified by name. Returns getNextSubForm() if an invalid
     * name or a form has been specified that is not valid and its order is 
     * greater than current sub form + 1.
     * @param string $subFormName
     * @return Zend_Form_SubForm
     */
    public function getSubForm($subFormName) {}

    /**
     * Checks if form is the last one of our base form.
     * @param string $subFormName
     * @return bool 
     */
    public function isLastSubForm($subFormName) {}

    /**
     * Returns amount of remaining sub forms not yet verified/completed.
     * @return int
     */
    public function getRemainingFormCount() {}

    /**
     * Checks if sub form is valid, i.e. valid data has been POSTed.
     * @param  Zend_Form_SubForm $subForm
     * @param  array $postData
     * @return bool
     */
    public function subFormIsValid(Zend_Form_SubForm $subForm, array $postData) {}

    /**
     * Checks if the *entire* form is valid, i.e. if all sub form have been validated.
     * @return bool
     */
    public function formIsValid($form, $postData) {}

    /**
     * Produces a breadcrumb trail based on the form's current status and attaches
     * it to the subform specified.
     * Sets the following meta-fields for each breadcrumb:
     * - form: name of the subform referenced
     * - label: label to display
     * - valid: if form is valid and complete
     * - enabled: if form is enabled to be processed (i.e. previous step is valid)
     * - active: if sub form specified is currently active (i.e. is current sub form)
     * @return Zend_Form_SubForm
     */
    public function addFormBreadCrumbs($subForm) {}

    /**
     * Adds CSRF protection to subform.
     * @param Zend_Form_SubForm $subForm
     * @return Zend_Form_SubForm 
     */
    protected function addCsrfProtection($form) {}

    /**
     * The last sub-form is to verify the entire form -> display form data submitted once more. 
     * This comes in handy if you want to users to present their data entered in all sub forms
     * once more to let them verify their input.
     * @param Zend_Form_SubForm $form
     * @return Zend_Form_SubForm 
     */
    public function attachFinalFormVerification($form) {}

    /**
     * Display <form> to add private user account and POST to addAction to 
     * process data.
     */
    public function indexAction() {}

    /**
     * This is were the actual form processing happens. Each submit button of each sub form
     * should be linked to this action as it contains the business logic of this multi page controller.
     */
    public function processAction() {}

    /**
     * Submission is complete, i.e. entire form has been verified.
     */
    public function completedAction() {}
    
    /**
     * This method has to be implemented in the inheriting class.
     * It contains all actions required to finally process the completed/valid form.
     * Takes data from session namespace.
     */
    protected function processValidForm() {}
}

As you can see the controller provides a lot of functionality to handle multi-page form submissions. Most important to note is the fact that form submissions are handled by processAction.

Again, based on this base multi-page controller class Controller_MultiPage we are ready to implement our concrete registration controller, Registration_Controller:

class Register_Controller extends Controller_MultiPage {

  protected $_formClass = 'Register_Form';
  protected $_formClassName = 'Register';
  protected $_verificationViewScript = '_verification.phtml';
  protected $_subFormLabels = array(
    'step1' => 'Step1',
    'step2' => 'Step2',
    'step3' => 'Step3');
  protected $_namespace = 'RegisterController';

  public function indexAction() {
    parent::indexAction(); //explicitely delegate to parent
  }

  public function processAction() {
    parent::processAction(); //explicitely delegate to parent
  }

  protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {
    $step = preg_replace('/[a-z]/i', '', $step);

    switch ($step) {
        default:
           return $cleanedPostData; //to be implemented, e.g. by checkStepX()
    }
  }

  /**
   * To be called when entire form has been validated.
   */
  protected function processValidForm() {
    $this->getSessionNamespace()->lock(); //lock against further editing
    $formData = $this->getSessionNamespaceData();
		
    //DO SOME PROCESSING...

    // remove session namespace
    $sessionNamespace = new Zend_Session_Namespace($this->_namespace);
    $sessionNamespace->unsetAll();
    Zend_Session::namespaceUnset($this->_namespace);
  }
}

Basically, Controller_MultiPage lacks the capability of checking sub form specific constraints during a submission. This functionality needs to be implemented by the concrete implementation’s checkStep, which again may delegate to other checking functions.

Once the entire form (i.e. all of its sub forms) have been submitted completely processValidForm should be called to do some final processing (i.e. persist the account to be registered). Additionally, it will first lock and then erase the session namespace after final submission.

Based on this architecture you are able to implement highly flexible multi-page forms using nothing more than Zend_Form and Zend_Form_SubForm.

Source files

Below you find the complete source code for this example consisting of the following files:

Form_MultiPage.php

/**
 * Base class for multipage forms.
 *
 * @author <matthias.kerstner>
 * @link https://www.kerstner.at
 */
class Form_MultiPage extends Zend_Form {

    /**
     * Prepare a sub form for display.
     *
     * @param  string|Zend_Form_SubForm $spec
     * @return Zend_Form_SubForm
     */
    public function prepareSubForm($spec, $isLastSubForm = false, $showSubmitButton = true) {
        $subForm = null;

        if (is_string($spec)) {
            $subForm = $this->{$spec};
        } elseif ($spec instanceof Zend_Form_SubForm) {
            $subForm = $spec;
        } else {
            throw new Exception('Invalid argument passed to ' .
                    __FUNCTION__ . '()');
        }

        $this->setSubFormDecoratorsT($subForm)
                ->addSubFormAction($subForm);

        if ($showSubmitButton) {
            $this->addSubmitButton($subForm, $isLastSubForm);
        }

        return $subForm;
    }

    /**
     * Sets default form decorators to sub form unless custom decorators have 
     * been set previously.
     *
     * @param  Zend_Form_SubForm $subForm
     * @return Form_MultiPage
     */
    public function setSubFormDecoratorsT(Zend_Form_SubForm $subForm) {
        $subForm->setDecorators(array('FormElements',
            array('HtmlTag', array('tag' => 'div',
                    'class' => 'mmsForm')),
            'Form'));

        return $this;
    }

    /**
     * Add a submit button to an individual sub form
     *
     * @param  Zend_Form_SubForm $subForm
     * @return Zend_Form
     */
    public function addSubmitButton(Zend_Form_SubForm $subForm, $isLastSubForm = false, $continueLabel = 'Save and Continue', $finishLabel = 'Finish') {

        $label = $isLastSubForm ? $finishLabel : $continueLabel;

        $subForm->addElement(new Form_Element_Submit(
                        'submit',
                        array(
                            'label' => $label,
                            'required' => false,
                            'ignore' => true
                        )
        ));

        return $this;
    }

    /**
     * Adds action and method to sub form.
     *
     * @param  Zend_Form_SubForm $subForm
     * @return My_Form_Registration
     */
    public function addSubFormAction(Zend_Form_SubForm $subForm) {
        $lang = Zend_Registry::get('Zend_Locale')->getLanguage();
        $view = Zend_Layout::getMvcInstance()->getView();
        $action = $view->url(array('language' => $lang, 'action' => 'process'), $view->multilanguage()->getCurrentRoute());

        $subForm->setAction($action)
                ->setAttrib('enctype', 'multipart/form-data')
                ->setMethod('post');
        return $this;
    }

}

Custom_Multipage_Controller.php

/**
 * A base controller class for multipage forms.
 *
 * Only valid data will be stored in session (sub-)namespace corresponding to 
 * subform and metadata field will be set accordingly.
 *
 * @author <matthias.kerstner>
 * @link https://www.kerstner.at
 */
class Custom_Controller_MultiPage extends Zend_Controller_Action {

    protected $_formClass;
    protected $_formClassName;
    protected $_form;
    protected $_verificationViewScript;
    protected $_namespace;
    protected $_session;

    /**
     * determines which sub-forms should *not* have CSRF-protection
     * @var array 
     */
    protected $_excludeCsrfSubForms = array();

    /**
     * Redirects user to index action if needed.
     * @param Zend_Form_SubForm $form 
     */
    protected function checkSessionStep(&$form) {
        $step = preg_replace('/[a-z]/i', '', $form->getName());
        $session = $this->getSessionNamespaceData();
        if ($step > 1 && !isset($session['step1'])) {
            //redirect user to start if previous steps have not yet been completed
            $this->_helper->redirector('index');
        }
    }

    /**
     * Default populate function. You can override this function in your concrete
     * controller.
     * @param type $form
     * @param type $data
     */
    protected function populateForm($form, $data) {
        /**
         * populate with data -> be aware that only fields set in 
         * this array will be populated!
         */
        return $form->populate($data);
    }

    /**
     * Custom subform pre checks/actions.
     * @param Zend_Form_SubForm $subForm
     * @param type $rawPostData
     * @return Zend_Form_SubForm
     */
    protected function preSubFormActions($subForm, $rawPostData) {
        return $subForm;
    }

    /**
     *
     * @param type $formName
     * @return type 
     */
    protected function checkAttachSubmitButton($formName) {
        return true; //default action unless overriden by concrete implementation
    }

    /**
     * Checks subform.
     * @param type $step, e.g. 'step'[NUM], e.g. "step1"
     * @param Zend_Form_SubForm $subForm
     * @param array $cleanedPostData
     * @param array $rawPostData 
     * @return bool|array
     */
    protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {
        $step = preg_replace('/[a-z]/i', '', $step);
        return $cleanedPostData;  //TO BE IMPLEMENTED IN CONCRETE CONTROLLER
    }

    /**
     * 
     */
    public function getForm() {
        if ($this->_form === null) {

            if ($this->_formClass == '')
                throw new Exception('No multipage form set');

            $this->_form = new $this->_formClass;

            if (!$this->_form)
                throw new Exception('No multipage form set');
        }

        return $this->_form;
    }

    /**
     * Get the session namespace we're using
     *
     * @return Zend_Session_Namespace
     */
    public function getSessionNamespace() {
        if (null === $this->_session) {

            if (empty($this->_namespace))
                throw new Exception('No namespace set for multipage form');

            $this->_session = new Zend_Session_Namespace($this->_namespace);
        }

        return $this->_session;
    }

    /**
     *
     * @return type 
     */
    public function getSessionNamespaceData() {
        $data = array();
        foreach ($this->getSessionNamespace() as $k => $v) {

            if (!is_array($v))
                continue;

            foreach ($v as $kForm => $vForm) {
                $data[$k] = $vForm;
            }
        }

        return $data;
    }

    /**
     * Get a list of forms already stored in the session
     *
     * @return array
     */
    public function getStoredForms() {
        $stored = array();
        foreach ($this->getSessionNamespace() as $key => $value) {
            $stored[] = $key;
        }

        return $stored;
    }

    /**
     * Get list of all subforms available
     *
     * @return array
     */
    public function getPotentialForms() {
        return array_keys($this->getForm()->getSubForms());
    }

    /**
     * What sub form was submitted?
     *
     * @return false|Zend_Form_SubForm
     */
    public function getCurrentSubForm() {
        $request = $this->getRequest();
        if (!$request->isPost()) {
            return false;
        }

        foreach ($this->getPotentialForms() as $name) {
            $data = $request->getPost($name, false);
            if ($data) {
                if (is_array($data)) {
                    return $this->getForm()->getSubForm($name);
                    break;
                }
            }
        }

        return false;
    }

    /**
     * Returns the "next" subform to display. If current request is not POST, 
     * i.e. the page was requested directly via GET the user is redirected to
     * the first subform. If the current subform is the last potential form it
     * will be returned again, otherwise the next subform will be returned.
     * @return Zend_Form_SubForm|false
     */
    public function getNextSubForm() {

        if ($this->getRemainingFormCount() < 1) {
            return false; //no more subforms to process
        }

        $storedForms = $this->getStoredForms();
        $potentialForms = $this->getPotentialForms();
        $sessionData = $this->getSessionNamespaceData();
        $currentSubForm = $this->getCurrentSubForm();

        if (!$currentSubForm) { //return first subform since no form was submitted (no POST)
            return $this->getForm()->getSubForm($potentialForms[0]);
        }

        $currentSubFormName = $currentSubForm->getName();
        $currentSubFormIdx = array_search($currentSubFormName, $potentialForms);

        if ($this->isLastSubForm($currentSubFormName)) {
            return $this->getForm()->getSubForm(end($potentialForms));
        }

        return $this->getForm()->getSubForm($potentialForms[$currentSubFormIdx + 1]);
    }

    /**
     * Returns subform specified by name. Returns getNextSubForm() if an invalid
     * name or a form has been specified that is not valid and its order is 
     * greater than current subform + 1.
     * @param type $subFormName
     * @return type 
     */
    public function getSubForm($subFormName) {

        $storedSubForms = $this->getStoredForms();

        if (in_array($subFormName, $storedSubForms)) { //requested subform is active already
            return $this->getForm()->getSubForm($subFormName);
        }

        return $this->getCurrentSubForm();
    }

    /**
     * Checks if form is last.
     * @param string $subFormName
     * @return bool 
     */
    public function isLastSubForm($subFormName) {
        $potentialSubForms = $this->getPotentialForms();
        return (array_search($subFormName, $potentialSubForms) >= (count($potentialSubForms) - 1));
    }

    /**
     *
     * @return type 
     */
    public function getRemainingFormCount() {
        $storedSubForms = $this->getStoredForms();
        $potentialSubForms = $this->getPotentialForms();
        $sessionData = $this->getSessionNamespaceData();
        $completedSubForms = 0;

        foreach ($storedSubForms as $name) {
            if (isset($sessionData[$name]['metadata']['complete']) &&
                    ($sessionData[$name]['metadata']['complete'])) {
                $completedSubForms++;
            }
        }

        return (count($this->getPotentialForms()) - $completedSubForms);
    }

    /**
     * Is the sub form valid?
     *
     * @param  Zend_Form_SubForm $subForm
     * @param  array $postData
     * @return bool
     */
    public function subFormIsValid(Zend_Form_SubForm $subForm, array $postData) {

        if ($subForm->isValid($postData)) {

            $subFormName = $subForm->getName();
            $formData = $subForm->getValues();

            // init metadata
            $formData[$subFormName]['metadata'] = array();

            // call custom checker function for subform
            $checkedFormData = $this->checkStep($subFormName, $subForm, $formData, $postData);

            if (!$checkedFormData) {
                return false; // custom subform check failed
            } else {
                $formData = $checkedFormData;
            }

            $formData[$subFormName]['metadata']['complete'] = true;

            $this->getSessionNamespace()->$subFormName = $formData; //overwrite existing values
            // activate next subform to be selectable (if not already activated)
            $potentialSubForms = $this->getPotentialForms();
            $storedSubForms = $this->getStoredForms();
            $currentSubFormIdx = array_search($subFormName, $potentialSubForms);

            // "unlock" next subform
            if ($currentSubFormIdx < (count($potentialSubForms) - 1)) {
                $nextSubFormName = $potentialSubForms[$currentSubFormIdx + 1];

                if (!isset($storedSubForms[array_search($nextSubFormName, $potentialSubForms)])) {
                    $this->getSessionNamespace()->$nextSubFormName = array();
                }
            }

            return true; //subform is valid
        }

        //set subset of VALID fields in session namespace
        $subSubForms = $subForm->getSubForms();
        $subFormName = $subForm->getName();
        $validSubFormFields = array();
        $subFormNames = array_keys($subSubForms);

        foreach ($subSubForms as $k => $v) {

            $elements = $v->getElements();
            $validSubFormFields[$k] = array();

            foreach ($elements as $kEl => $vEl) {
                //check if field has been posted and is valid
                //be sure to specifiy context for isValid since it is required
                //by certain validators, such as identical
                if (isset($postData[$subFormName][$k][$kEl]) &&
                        $vEl->isValid($postData[$subFormName][$k][$kEl], $postData[$subFormName][$k])) {
                    $validSubFormFields[$k][$kEl] = $vEl->getValue();
                }
            }
        }

        //register (partial) valid data in session namespace for subform
        $this->getSessionNamespace()->$subFormName = array($subFormName => $validSubFormFields);

        //mark as invalid (again)
        $formData[$subFormName]['metadata']['complete'] = false;

        return false;
    }

    /**
     * Is the full form valid?
     *
     * @return bool
     */
    public function formIsValid($form, $postData) {

        if ($this->getRemainingFormCount() >= 1)
            return false;

        //final submission only allowed using submit button and not breadcrumbs
        return isset($postData[$form->getName()]['submit']);
    }

    /**
     * Produces a breadcrumb list based on the form's current status and attaches
     * it to the subform specified.
     * Sets the following meta-fields for each breadcrumb:
     * - form: name of the subform referenced
     * - label: label to display
     * - valid: if form is valid and complete
     * - enabled: if form is enabled to be processed (i.e. previous step is valid)
     * - active: if subform specified is currently active (i.e. is current subform)
     * @return Zend_Form_SubForm
     */
    public function addFormBreadCrumbs($subForm) {
        $formLabels = $this->_subFormLabels;
        $storedForms = $this->getStoredForms();
        $potentialForms = $this->getPotentialForms();
        $subFormName = $subForm->getName();
        $sessionData = $this->getSessionNamespaceData();
        $breadCrumbs = array();

        foreach ($storedForms as $k => $v) { //mark enabled/valid subforms
            $breadCrumbs[] = array(
                'form' => $v,
                'label' => $formLabels[$v],
                'enabled' => true,
                'active' => false,
                'valid' => (isset($sessionData[$v]['metadata']['complete']) &&
                $sessionData[$v]['metadata']['complete']));
        }

        foreach ($potentialForms as $v) { //add incomplete/missing subforms
            if (!in_array($v, $storedForms)) {
                $breadCrumbs[] = array(
                    'form' => $v,
                    'label' => $formLabels[$v],
                    'enabled' => false,
                    'active' => false,
                    'valid' => false);
            }
        }

        //set current form-breadcrumb active
        foreach ($breadCrumbs as $k => $breadCrumb) {
            if ($breadCrumb['form'] == $subFormName) {
                $breadCrumbs[$k]['active'] = true;
            }
        }

        // sort breadcrumbs ascending
        function valSort($a, $b) {
            return strtolower($a['form']) > strtolower($b['form']);
        }

        usort($breadCrumbs, 'valSort');

        //activate first button by default
        $breadCrumbs[0]['enabled'] = true;

        $breadCrumbSubForm = new Form_BreadCrumbs();

        $subForm->addSubForm($breadCrumbSubForm->addBreadCrumbs($breadCrumbs), 'breadCrumbs', 1000); //1000=p at the very end of the form

        return $subForm;
    }

    /**
     *
     * @param type $subForm
     * @return type 
     */
    protected function addCsrfProtection($form) {
        //TODO: add your CSRF protection here

        return $form;
    }

    /**
     * The last sub-form is verification -> display form data submitted once more
     * @param Zend_Form_SubForm $form
     * @return Zend_Form_SubForm 
     */
    public function attachFinalFormVerification($form) {

        $translate = Zend_Registry::get('Zend_Translate');
        $potentialSubForms = $this->getPotentialForms();

        if ($form->getName() != end($potentialSubForms)) {
            return $form; // form submitted is !last subform
        }

        $invalidForms = '';
        $sessionData = $this->getSessionNamespaceData();
        $moduleName = $this->getRequest()->getModuleName();

        foreach ($potentialSubForms as $formName) {
            if ($formName != end($potentialSubForms) &&
                    (!isset($sessionData[$formName]) ||
                    !isset($sessionData[$formName]['metadata']['complete']) ||
                    !$sessionData[$formName]['metadata']['complete'])) {
                $invalidForms .= "<li>" . $translate->_(mb_strtoupper($moduleName) . '.' .
                                mb_strtoupper($this->_formClassName) . '.FORMSTEP.' . $formName) . "</li>";
            }
        }

        $html = '';

        if ($invalidForms) {
            $html .= '<div id="VerificationInvalidFormsWarningContainer">';
            $html .= $translate->_('FORM.VERIFICATION.ERRORMSG');
            $html .= '<ul>' . $invalidForms . '</ul>';
            $html .= '</div>';
        }

        //TODO: attach $html to $form

        return $form;
    }

    /**
     * Display <form> to add private user account and POST to addAction to 
     * process data.
     */
    public function indexAction() {
        // Either re-display the current page, or grab the "next"
        // (first) sub form
        if (!$form = $this->getCurrentSubForm()) {
            $form = $this->getNextSubForm();
        }

        if (!$form) { //we are already done processing the form
            return $this->render('done');
        }

        $formSessionData = $this->getSessionNamespaceData();
        $form = $this->attachFinalFormVerification($form);
        $form = $this->addFormBreadCrumbs($form);
        $form = $this->addCsrfProtection($form);
        $form = $this->getForm()->prepareSubForm($form, false, $this->checkAttachSubmitButton($form->getName()));
        $form = $this->populateForm($form, $formSessionData);
        $this->view->form = $form;
    }

    /**
     *
     * @return type 
     */
    public function processAction() {

        if (!$form = $this->getCurrentSubForm()) { // no active subform
            return $this->_forward('index');
        }

        $postData = $this->getRequest()->getPost();
        $nextSubFormToLoad = null;

        // breadcrumb selected -> jump to this subform after saving current subform
        if (isset($postData[$form->getName()]['breadCrumbs'])) {
            $breadCrumbSubmit = array_keys($postData[$form->getName()]['breadCrumbs']);
            $nextSubFormToLoad = $this->getSubForm($breadCrumbSubmit[0]);
        }

        // actions/checks to be carried out PRIOR to actual validation
        $this->checkSessionStep($form);
        $form = $this->preSubFormActions($form, $postData);

        // check submitted subform
        if (!$this->subFormIsValid($form, $postData)) {

            // set next subform to load based on breadcrumb navigation
            if ($nextSubFormToLoad != null) {
                $form = $nextSubFormToLoad;
            }

            $formSessionData = $this->getSessionNamespaceData();
            $form = $this->populateForm($form, $formSessionData);

            if ($nextSubFormToLoad != null) { // validate to show errors when navigating via breadcrumbs
                $form->isValid($formSessionData);
            }

            $form = $this->preSubFormActions($form, $postData);
            $form = $this->addFormBreadCrumbs($form);
            $form = $this->attachFinalFormVerification($form);
            $form = $this->addCsrfProtection($form);
            $this->view->form = $this->getForm()->prepareSubForm($form, $this->isLastSubForm($form->getName()), $this->checkAttachSubmitButton($form->getName()));

            return $this->render('index');
        }

        // check entire form
        if (!$this->formIsValid($form, $postData)) {

            // set next subform to load
            if ($nextSubFormToLoad != null) {
                $form = $nextSubFormToLoad; // breadcrumb navigation
            } else {
                $form = $this->getNextSubForm(); // next step 
            }

            $formSessionData = $this->getSessionNamespaceData();
            $form = $this->populateForm($form, $formSessionData);

            if ($nextSubFormToLoad != null) { // validate to show errors when navigating via breadcrumbs
                $form->isValid($formSessionData);
            }

            $form = $this->preSubFormActions($form, $postData);
            $form = $this->addFormBreadCrumbs($form);
            $form = $this->attachFinalFormVerification($form);
            $form = $this->addCsrfProtection($form);
            $this->view->form = $this->getForm()
                    ->prepareSubForm($form, $this->isLastSubForm($form->getName()), $this->checkAttachSubmitButton($form->getName()));

            return $this->render('index');
        }

        try { // valid and complete form data received
            // persist data
            $this->processValidForm();
            // unset session to prevent double submissions
            $this->getSessionNamespace()->unsetAll();
            Zend_Session::namespaceUnset($this->_namespace);
            // redirect to prevent double posting
            $this->_helper->redirector('completed', null, null, array('language' => $this->getCurrentLanguage()));
        } catch (Exception $e) {
            throw new Exception('Failed to add account: ' . $e->getMessage());
        }
    }

    /**
     * 
     */
    public function completedAction() {
        return $this->render('done');
    }

    /**
     * This method has to be implemented in the inheriting class
     * Here are all the actions done to finally process the complete and valid form.
     * Takes data from session namespace.
     */
    protected function processValidForm() {
        throw new Exception('This method has to be implemented in the inheriting class');
    }

}

Form_BreadCrumbs.php

Finally, if you are dealing with multiple subforms you want some sort of breadcrumb navigation:

/**
 * @author matthias.kerstner
 */
class Form_BreadCrumbs extends Zend_Form_SubForm {

    /**
     *
     * @param array $breadCrumbs 
     */
    public function addBreadCrumbs($breadCrumbs) {

        foreach ($breadCrumbs as $k => $v) {

            $attribs = array('class' => 'button');

            $breadCrumb = new Zend_Form_Element_Submit(
                            $v['form'],
                            array(
                                'label' => _($v['label']),
                                'required' => false,
                                'ignore' => true,
                                'order' => $k,
                                'decorators' => array(
                                    'viewHelper',
                                    'Errors'
                            )));

            /**
             * set attributes based on status
             */
            if (!$v['enabled']) {
                $attribs['disabled'] = 'disabled';
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "disabled";
            } else {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "enabled";
            }
            if ($v['active']) {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "active";
            }
            if ($v['valid']) {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "valid";
            } else {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "invalid";
            }

            $breadCrumb->setAttribs($attribs);

            $this->addElement($breadCrumb);
        }

        return $this;
    }
        
    public function addBreadCrumbsToMenu($breadCrumbs) {
		$menulink = '<dt id="breadCrumbs-label">&#160;</dt><dd id="breadCrumbs-element"><fieldset id="fieldset-breadCrumbs"><dl>';
		foreach ($breadCrumbs as $k => $v) {
			$menulink .= '<input type="submit" name="step'.$k.'[breadCrumbs][step'.$k.']" id="step'.$k.'-breadCrumbs-step'.$k.'" value="'.$v['label'].'" class="';
			$attribs = array('class' => 'button');
			if (!$v['enabled']) {
                $attribs['disabled'] = 'disabled';
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "disabled";
            } else {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "enabled";
            }
            if ($v['active']) {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "active";
            }
            if ($v['valid']) {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "valid";
            } else {
                $attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "invalid";
            }
            $menulink .=  $attribs['class'];
            $menulink .=  '" />';
        }

        echo $menulink;
        return $this;
    }

}