Posted on Leave a comment

Disable Zend View Renderer

Zend Framework Logo

In case you need to disable Zend View’s Renderer because you want to return custom data (JSON, XML, etc.) simply using the following lines to do so:

$this->_helper->layout->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);

Then you can return your response in any format you want, just don’t forget to set the content type accordingly.

That’s it.

Posted on 2 Comments

Custom Zend_Form view script

Zend Framework Logo

Sometimes Zend’s built-in form decorators are not flexible enough to achieve customized views. Luckily, Zend provides means to specify custom view scripts. The following example demonstrates how to set and handle such a script.

First and foremost, in your default (action) view script simply output the form as usual:

<div class="formContainer">
  <?php echo $this->form ?>
</div>

Then, in your derived Zend_Form class set your custom view script that takes care of rendering the form the way you need it:

/**
 * Custom Zend_Form view script example.
 * @author matthias.kerstner
 */
class Custom_Form extends Zend_Form {

    public function init() {
        $this->setDecorators(array(array(
                'viewScript',
                array(
                    'viewScript' => '_customForm.phtml',
                    array('formId' => 'CustomForm', 'formClass' => 'customForm')
            ))));

Finally, the custom Zend_Form view script looks like the following. Note that it uses additional parameters that you can specify in Custom_Form.

<?php
$params = $this->element->getDecorator('ViewScript')->getOptions();
$formId = $params[0]['formId'];
$formClass = (isset($params[0]['formClass'])) ? $params[0]['formClass'] : '';
?>

<form id="<?php echo $formId ?>" 
      class="<?php echo $formClass ?>"
      method="<?php echo $this->element->getMethod() ?>" 
      action="<?php echo $this->element->getAction() ?>" 
      enctype="<?php echo $this->element->getEnctype() ?>">

    <?php
    echo $this->element->my_element_1;
    echo $this->element->my_element_2;

    echo $this->element->submit;
    ?>

</form>

Don’t forget to call parent::init(); in any of your derived classes. Using custom view scripts gives you the full power to design your form view, but remember that in contrast to using built-in decorators you need to echo your elements manually in your view script.

Posted on Leave a comment

Control Zend’s layout rendering and Content-Type specifications

Zend Framework Logo

In case you need to return content types other than the one set by server (Apache, etc.) or Zend itself use the following lines to quickly control Zend’s layout rendering and Content-Type specification.

This is crucial for instance if your controller is required to return JSON or XML as part of a service request.

// in your controller
// 1. disable layout rendering
$this->_helper->layout->disableLayout();
$this->_helper->viewRenderer->setNoRender();

// 2. customize Content-Type
$this->getResponse()->setHeader('Content-Type', 'application/json; charset=utf-8');

// 3. echo' content as usual, e.g. JSON
echo json_encode(array('foo' => 'bar'));
Posted on Leave a comment

Zend_Mail Plugin abstraction class

Zend Framework Logo

From time to time it is handy to be able quickly set special options when sending mails using Zend_Mail, such as automatically adding footer text or toggling between HTML and plaintext mails. In order to satisfy these needs I wrote the following simple abstraction class as a wrapper around Zend_Mail.

/**
 * An abstraction class for Zend_Mail.
 * @author matthias.kerstner
 */
class Custom_Plugin_Mailer {

    /**
     * Assembles $mail but does not send it.
     * @param array $to
     * @param string $subject
     * @param string $body
     * @param array? $from
     * @param bool? $html
     * @param bool? $appendSignature
     * @param bool? $archive
     * @param string? $encoding
     * @param Zend_Locale? $locale
     * @return bool|Zend_Mail 
     */
    public static function assemble($to, $subject, $body, $from = null, $html = false, $appendSignature = true, $archive = true, $encoding = 'UTF-8', $locale = null) {
        return self::send($to, $subject, $body, $from, $html, $appendSignature, $archive, $encoding, false, $locale);
    }

    /**
     * Sends mail.
     * @param array $to [0=email, 1=name]
     * @param string $subject
     * @param string $body
     * @param array? $from [0=email, 1=name]
     * @param bool? $html
     * @param bool? $appendSignature
     * @param bool? $archive
     * @param string? $encoding
     * @param bool? $sendDirectly
     * @param Zend_Locale? $locale
     * @param array? $toBCC [0=email, 1=name]
     * @return bool|Zend_Mail true on success, false on error, Zend_Mail 
     *         if $sendDirectly is false
     */
    public static function send($to, $subject, $body, $from = null, $html = false, $appendSignature = true, $archive = true, $encoding = 'UTF-8', $sendDirectly = true, $locale = null, $toBCC = null) {
        $config = Zend_Registry::get('Zend_Config');
        $translate = Zend_Registry::get('Zend_Translate');
        $_locale = $locale instanceof Zend_Locale ? $locale : Zend_Registry::get('Zend_Locale');
        $mail = new Zend_Mail($encoding);

        if (APPLICATION_ENV !== 'production') { // DEBUG MODE
            $mail->setFrom($config->email->noreply->email, $config->email->noreply->sender);
            $mail->addTo($config->email->debug->email, $config->email->debug->sender);
        } else { // PRODUCTION MODE
            if ($from === null) {
                $mail->setFrom($config->email->noreply->email, $config->email->noreply->sender);
            } else {
                if (is_array($from) && count($from) == 2) {
                    $mail->setFrom($from[0], $from[1]);
                } else {
                    throw new Exception('Failed to send email');
                }
            }

            if (is_array($to) && count($to) == 2) {
                $mail->addTo($to[0], $to[1]);
            } else {
                throw new Exception('Failed to send email');
            }
        }

        $mail->setSubject($subject);

        if ($appendSignature) {
            $br = $html ? "<br>" : "\n";
            $body .= $br . $br
                    . $translate->_('DEFAULT.MAIL.SIGNATURE', $_locale);
        }

        if ($html) {
            $mail->setBodyHtml($body);
        } else {
            $mail->setBodyText($body);
        }

        if ($archive) {
            $mail->addBcc($config->email->archive->email, $config->email->archive->sender);
        }

        // BCC
        if (is_array($toBCC) && count($toBCC) == 2) {
            $mailBCC = new Zend_Mail($encoding);
            $mailBCC->setFrom($config->email->noreply->email, $config->email->noreply->sender);
            $mailBCC->addTo($toBCC[0], $toBCC[1]);
            $mailBCC->setSubject("BCC: " . $subject);
            if ($html) {
                $mailBCC->setBodyHtml("BCC:<br />" . $body);
            } else {
                $mailBCC->setBodyText("BCC:\n" . $body);
            }
            $mailBCC->send();
        }

        if (!$sendDirectly) {
            return $mail;
        }

        try {
            if (!$mail->send()) {
                throw new Exception('Failed to send mail to "'
                        . $to[0] . '" with subject "' . $subject . '"');
            }
        } catch (Exception $e) {
            return false;
        }

        return true;
    }

}
Posted on Leave a comment

File Uploads using Zend_Form

Zend Framework Logo

File uploads can be managed quite easily using Zend_Form. First off, create a Zend_Form instance and add a Zend_Form_Element_File element, representing the file-input element. Make sure to set the encoding type to multipart/form-data:

class Form_FileUpload extends Zend_Form {
    
    public function init() {

        $this->setAttrib('enctype', 'multipart/form-data');

        $element = new Zend_Form_Element_File('image');
        $element->setLabel('Some picture');
        $element->addValidator('Count', false, 1);
        $element->addValidator('Size', false, 204800);
        $element->addValidator('Extension', false, 'jpg,png,gif');
        $element->setRequired(true);

        $this->addElement($element);

        $this->addElement('text', 'someText', array(
            'filters' => array('StringTrim'),
            'label' => _('Some Text'),
            'decorators' => array(
                'viewHelper', array(
                    'Label', array('class' => 'label')
                ),
                'Errors'
            ),
            'validators' => array(
                array('StringLength', true, array(5, 255))),
            'id' => 'SomeText'
        ));
}

Now that we have a form let’s add the logic in the corresponding controller. We only need one action (imageAction) to process POST requests and GET requests:

public function imageAction() {

  $form = new Form_FileUpload();

  if ($this->getRequest()->isPost()) {
    $post = $this->getRequest()->getPost();

    if ($form->isValid($post)) {
     $upload = new Zend_File_Transfer_Adapter_Http();
     $upload->setDestination(realpath(APPLICATION_PATH . '\..\data\upload'));

     try { //be sure to call receive() before getValues()
       $upload->receive();
     } catch (Zend_File_Transfer_Exception $e) {
       $e->getMessage();
     }

     $formData = $form->getValues(); //be sure to call this after receive()

     $filename = $upload->getFileName('image'); //optional info about uploaded file
     $filesize = $upload->getFileSize('image');
     $filemimeType = $upload->getMimeType('image');

     $dstFilePath = '/images/'.$filename;

     $filterFileRename = new Zend_Filter_File_Rename(array('target' => $dstFilePath, 'overwrite' => true));
     $filterFileRename->filter($filename); //move uploade file to destination
   }
 }

 $this->view->form = $form;
}

That’s all there is to it. receive() processes the uploaded file, i.e. checks if everything went well. In order to move uploaded files to certain destination folders you can use Zend_Filter_File_Rename. Unlike the name suggests it does not only rename files but also is capable of moving files.

Make sure to call receive() before getValues() as Zend_Form otherwise messes up the file upload.

Posted on Leave a comment

Custom Zend Validator for Localized Numbers

Zend Framework Logo

When dealing with i18n web applications the need to support localized number representations becomes obvious. Thus, users should be able to enter localized numbers such as “1.000.50 $” when for instance using the locale en_US or “1,000.50 $” when using de_AT. Now, when it comes to validating user input throughZend_Form Zend provides special validators to do so. As we want to store only normalized numeric values we need to convert user input from possible localized representations to their normalized counterparts.

Zend_Filter_LocalizedToNormalized to the rescue

Luckily, in order to convert localized representations to normalized versions Zend provides us with Zend_Filter_LocalizedToNormalized. The following example shows a form element that makes use of this filter:

$this->addElement(new Form_Element_Float('some_float', array(
                    'filters' => array('StringTrim', 'LocalizedToNormalized'),
                    'required' => false,
                    'label' => _('some float'),
                    'decorators' => array(
                        'viewHelper', array(
                            'Label', array('class' => 'label')
                        ),
                        'Errors'
                    ),
                    'validators' => array(
                                new Form_Validate_FloatNormalized(),
                                new Zend_Validate_Between(array('min' => 0, 'max' => 100))
                    ),
                    'id' => 'SomeFloat'
                )));

As you see I’ve used 2 filter: StringTrim and LocalizedToNormalized. Furthermore, I have used a custom form element type called Form_Element_Float:

class Form_Element_Float extends Zend_Form_Element_Text {
public function render(Zend_View_Interface $view = null) {
        $content = parent::render($view);

        if (!is_numeric($this->_value)) {
            return $content;
        }

        return preg_replace('/value="' . $this->_value . '"/', 'value="'
                        . ($this->_value != null ? Zend_Locale_Format::toNumber($this->_value, array('locale' => Zend_Registry::get('Zend_Locale'), 'precision' => 2)) : '')
                        . '"', $content);
    }
}

This custom form element makes sure that Zend renders numeric values correctly, i.e. in a localized manner, based on the user’s locale. Now that we have defined the filters and the view rendering, it is time to finally talk about the validator: Form_Validate_FloatNormalized. As you might hasve guessed correctly this validator is for float values. The idea behind it is to check the outcome of the filters described above. So, ideally Zend_Filter_LocalizedToNormalized successfully converted the raw user input into the corresponding normalized representation (1.000.50 to 1000.5). Now it is time for Form_Validate_FloatNormalized to actually validate the converted value:

class Form_Validate_FloatNormalized extends Zend_Validate_Float {
    public function isValid($value) {
        // do not validate parent as we want to check against *normalized* values only (ignoring any locale)
        try {
            if (Zend_Locale_Format::getFloat($value, array('precision' => 2,
                        'locale' => 'en_US')) !==
                    (float) $value) { //normalized check only (use locale "en_US" as template pattern), i.e. allow only '.' as decimal seperator       	
                $this->_error(self::NOT_FLOAT);
                return false;
            }
        } catch (Zend_Locale_Exception $e) {
            $this->_error(self::NOT_FLOAT);
            return false;
        }

        return true;
    }
}

Form_Validate_FloatNormalized does nothing more then rely on Zend_Locale_Format to convert the filtered (i.e. normalized) value to the expected value. If they are they same the input is correct, otherwise we set the corresponding error message. Using this approach you can be sure to detect any non-numeric inputs. Furthermore, you support all languages and their corresponding localized representations that Zend supports.

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

Posted on Leave a comment

Zend 1.11.6 and Doctrine 1.2

Zend Framework Logo

Recently, our project team spent quite some time figuring out a way to get Zend (ZF) working together with Doctrine ORM. Although there are a lot of howtos and guides out there it seems like everybody has his/her own way of merging these two frameworks.

In order to get most out of Doctrine one has to get the command line tool running, which provides a handy set of predefined functions to manage the underlying model schema. From what we’ve read so far there are major differences between Doctrine 1.2 and 2.x, especially when it comes to setting up the command line script. Again, although there are plenty of different approaches to get Zend working with Doctrine 2.x no ideal solution could be found so far…

For compatibility reasons we opted for Doctrine 1.2, as it seems much easier to get it working with ZF. Once the command line script (doctrine.php) was setup (which took quite some time, as the bootstrapping process needs to be tweaked a bit), the next step was to figure out a way to autoload Doctrine’s models using Zend’s bootstrapper.

As we are using a module based architecture and are keeping the models outside this module structure (in application/models) we needed a way to autoload the modules separately from the models, without running into the problem of loading anything twice (which would happen when using the default setup, i.e. Doctrine’s model loader together with Zend’s autoloader…).

Anyway, the solution was to use Zend’s module autoloader and use module bootstrap files on the one hand, and additionally use Doctrine’s modelsAutoload function via a callback:

public function _initDoctrine() {
$this->getApplication()->getAutoloader()
->pushAutoloader(array('Doctrine', 'autoload'));
spl_autoload_register(array('Doctrine', 'modelsAutoload'));
$doctrineConfig = $this->getOption('doctrine');
$manager = Doctrine_Manager::getInstance();
$manager->setAttribute(Doctrine::ATTR_AUTO_ACCESSOR_OVERRIDE, true);
$manager->setAttribute(Doctrine::ATTR_MODEL_LOADING, $doctrineConfig['model_autoloading']);
$manager->setAttribute(Doctrine::ATTR_AUTOLOAD_TABLE_CLASSES, $doctrineConfig['table_autoloading']);
$manager->setAttribute(Doctrine::ATTR_DEFAULT_TABLE_CHARSET, 'utf8');
//loading of Doctrine's models is done by Zend's autoloader -> @see _initAppAutoload
//Doctrine_Core::loadModels($doctrineConfig['models_path']);
$conn = Doctrine_Manager::connection($doctrineConfig['dsn'], 'doctrine');
$conn->setAttribute(Doctrine::ATTR_USE_NATIVE_ENUM, true);
$conn->setCharset('UTF8');
return $conn;
}

The above code loads Doctrine and sets up the connection details. In order to bootstrap the modules use the following autoloader function:

protected function _initAppAutoload() {
$moduleLoader = new Zend_Application_Module_Autoloader(array(
'namespace' => '',
'basePath' => dirname(__FILE__)));
}

This setup parses all of the applications script and models. This setup can also be used for Doctrine’s command line script (doctrine.php).

The settings used for Doctrine are shown in the following:

doctrine.cache = true
doctrine.dsn = "mysql://user@localhost/some_db"
doctrine.data_fixtures_path = APPLICATION_PATH "/configs/data/fixtures"
doctrine.sql_path = APPLICATION_PATH "/configs/data/sql"
doctrine.migrations_path = APPLICATION_PATH "/configs/migrations"
doctrine.yaml_schema_path = APPLICATION_PATH "/configs/schema.yml"
doctrine.models_path = APPLICATION_PATH "/models"
doctrine.generate_models_options.pearStyle = true
doctrine.generate_models_options.generateTableClasses = true
doctrine.generate_models_options.generateBaseClasses = true
doctrine.generate_models_options.baseClassPrefix = "Base_"
doctrine.generate_models_options.baseClassesDirectory =
doctrine.generate_models_options.classPrefixFiles = false
doctrine.generate_models_options.classPrefix = "Model_"
doctrine.table_autoloading = true
; Doctrine needs to use Aggressive autoloading for the CLI to generate prefixed models appropriately
; MODEL AUTOLOADING: 1 = aggressive, 2 = conservative
doctrine.model_autoloading = 2
[doctrineCLI : production ] 
doctrine.model_autoloading = 1

This setup shown above creates a Base folder inside the application/models folder and puts the auto-generated Doctrine models in there. We believe this setup is more convenient to use in comparison to put the base-model files in the core models folder too.

Additionally, the setup specifies that model-classes should be prefixed with “Model_” to make them more distinct from other Zend classes. Bear in mind that this approach comes with certain changes when using fixtures for instance, as you have to explicitely prefix model-class references.