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;
    }

}

You may also like...

40 Responses

  1. roan says:

    Hi, matthias.kerstner could you please post the complete example along with view and how you are handling the previous and next button. That would be very helpful.

    • Hello rohankoid,
      yes I will try to update the post in the upcoming days. Please check back later. Cheers

    • Hello roan,

      as requested by quite some people who contacted via email I’ve added the complete source code. When I find time I will upload a sample working Zend project that incorporates the multipage form functionality.

      Feel free to post any questions and improvements to the code. Cheers

  2. Jeremy says:

    What does the method getSubFormGeoNamesFieldPairs do?

    • Hello Jeremy,

      this function has nothing to do with the controller’s original purpose. It was a relict from a previous project. I’ve removed it from the post. Thanks for pointing it out.

  3. Jeremy says:

    Hi Matthias,

    Do you have an example of Form_BreadCrumbs? Thanks.

  4. kigzz says:

    can you give an example where user can resume answering later?

    • Hello kigzz,
      the solution presented in this post is built on Zend_Session, as you can see in getSessionNamespaceData(). Thus, posted data that has been validated is stored in a special session namespace. Notice that only valid data is stored in this session namespace, since this data will be used for the final (overall) isValid() call on the outer form. Posted data that failed to validate will be set for the subform before displaying it.

      Since this solution depends on Zend_Session you can easily customize session specific settings, such as the session lifetime, etc. An entry point to this session data is provided by getSessionNamespace(). Thus, in order to resume any previous sessions simply call getSessionNamespaceData().

      I hope this answers your question.

      • kigzz says:

        Thanks for a quick reply…

        What if, user clears his session or transfer from one pc to another and the session is lost.

        Is there another way where users can continue answering the multi form few days later?

        • Hello kigzz,
          please have a look at how session data is stored using cookies (http://en.wikipedia.org/wiki/HTTP_cookie) as you need to understand this concept to also understand how browser sessions are handled.

          To answer your second question: You could try to set a permanent cookie, or at least set the session lifetime to a corresponding value. Thus, it all depends on how long your browser keeps session data before throwing it away.

  5. chinaski says:

    I’m just trying to get this example working. If you could post a zip of your full, working sample project I would appreciate it greatly. I’m currently experiencing zend translate errors when I run the code. I’ll figure it out, but if you have it working, all the better to post the code.

    Thanks in advance…

    • Hey chinaski,

      unfortunately I didn’t have time yet to build a reference Zend package to download. I will try to do this in the next couple of days. Please check back again at a later time.

      Cheers

      • chinaski says:

        Looking forward to that…thanks.

      • lucas says:

        hey matthias, any news on this ?
        It will really helpful for some people like me to have this.

        Could you upload a project sample ?
        as I could see.. you mentioned a github repository with the sample project

        I downloaded it… but its an empty project

  6. Larry says:

    Hi Matthias,

    Thanks for what looks to be a great example. I’m having trouble getting it running though. I get:
    Fatal error: Call to undefined function _() in /Users/larrysimon/Sites/zend-multipage-forms-master/application/forms/BreadCrumbs.php on line 20

    Custom_Controller_Action also appears to be missing.

  7. tery says:

    hi
    I am trying use your code. checked out today . it says Class ‘Custom_Controller_MultiPage’ not found in indexcontroller

  8. tery says:

    i got above working. but now it says
    Fatal error: Class ‘Form_MultiPage’ not found in application\modules\register\forms\User.php on line 6

  9. tery says:

    sorry to bother you. i got above working as well.
    i have imported your module into my existing project. when i first load /register. it display my css and style. but as soon as i click next, step 2 display your css.
    how can i overcome this.
    thnx

    • Hi tery,

      sorry for the late reply. You can control CSS files either via your layout script (layout.phtml) or using some logic in your controller. It sound like you are setting some CSS file in your first controller (or custom module view) when calling /register. Once the view is rendered using the multipage-controller

      $this->headLink()->appendStylesheet($this->baseUrl() . ‘/css/layout.css’);

      is called (inside layout.phtml), which sets/overrides some default styles.

      So please make sure to check if you are using layout.css twice and if so keep the include order in mind. You can easily change the order by either prepending or appending stylesheets.

      Cheers

  10. mills says:

    Hallo Matthias,

    thanks for the form. One question … I want render my verification data before I display the recaptcha and some checkboxes. But I can display it only after the form – how can I fix it?

    Best Regards and thanks

    • Hi mills,

      please have a look at attachFinalFormVerification() in the custom MultiPage controller class. Use this as starting point for your changes to the verification view. Hope that helps. Cheers

  11. mills says:

    Thanks a lot. I’ve founded. I don’t wrapped the elements in another Subform. Now it works. Thank you.

    In addition I have extend the Multipage with the following function:

    public function buildProcessRoute() {

    $module = Zend_Controller_Front::getInstance()->getRequest()->getModuleName();
    $controller = Zend_Controller_Front::getInstance()->getRequest()->getControllerName();
    $action = ‘process’;

    // $processRoute = $module . ‘_’ . $action;

    $processRoute = $module . ‘_’ . $controller . ‘_’ . $action;

    return $processRoute;

    }

    Now I can use it like:

    $form = $this->getForm()->prepareSubForm($form, $this->view->url(array(), $this->buildProcessRoute()), false, $this->checkAttachSubmitButton($form->getName()));

    My translated Routes I named like foo_index_baz

    Now it is reusable for another Mulitpage forms in the project.

    Thanks for this nice solution.
    Best regards

  12. Noury says:

    Hi Matthias,
    Yes it work very good, i need just functionality to jump from form_page to form_page without fil in the prevous forms. you said “this functionality is already available in the latest version of this implementation which I am going to commit to GitHub tomorrow”. Did you commied that. Thank you very much

    • Hi Noury,
      yes, this functionality is available in the latest version. I just haven’t had the time to commit these changes so far. I’ll try my best to commit tomorrow 😉 sorry for the delay. Cheers

  13. Noury says:

    Hi Matthias,
    nice to hear that this feature is already available in the latest version. Would you like to commit this functionality. 😉 and thank you. Cheers

    • Hey Noury,
      I’ve finally found time to commit the latest version. Please check out the GitHub repository. As promised, it includes functionality to directly jump to subforms, as well as to specify seperate view scripts for each step. Cheers

  14. Nour says:

    Hi Matthias,

    thanks for commiting. One question, which method (s) can i use to jump to an other step without fill in the previous steps?

    Thanks for this nice solution.
    Best regards

  15. Viktor says:

    Hi Matthias,
    I am just starting with ZF2, and I am disparately looking for a similar tutorial on multi-page (possibly tabbed) forms for ZF2. Are you by any chance aware how it can be done? Or maybe you know a good tutorial.

    Thanks.

    • Hi Viktor,
      it should not be that hard to migrate the zf1 example presented here to zf2. I hope that I will have some spare time to give it a quick shot. I will post back with info here. Cheers

Leave a Reply

Your email address will not be published. Required fields are marked *