Building Sample Web Site Based On Zend Framework Series

Part 4 – Zend_Form, CAPTCHA, Password Confirmation, Date selector field, Zend_Translate

Please, note that this text is written in May 2008. Since then Zend Framework have evolved and now provides Zend_Application class. It is good idea to use it. The user registration and login stuff, presented here is still actual though. Also there are many other ideas presented, which I hope will be usefull for the readers.

Long title for part 4 and much new things to cover while building the registration form. In this part I will cover just the registration form, and not the actual registration process, but believe me – this is not small topic at all :)

EDIT: This code is written with Zend Framework version 1.5. Since then version 1.6 have been released. There we can find many new components, along with Zend_Captcha and Zend_Form_Element_Captcha. It takes different approach than mine in showing the captcha, you can still take a look at my code for an example how to build components that you need but are not shipped with Zend Framework. But I recommend in new projects to use Zend_Captcha and Zend_Form_Element_Captcha.

First we will write the outline for our User controller. We will rely for now on the default URLS provided to us by Zend_Controller_Router_Rewrite, so assuming we are running from localhost these are the actions we will have for the user controller:

http://localhost/user/login

http://localhost/user/register

http://localhost/user/lostpassword

http://localhost/user/activate – here users will activate their profiles after registration (clicking on a link in sent email)
http://localhost/user/profile – here users will be able to edit their profiles

and of course we need index action – default action, which for now we will redirect to the register action. So here is the outline of
application/modules/default/controllers/UserController.php‘:

< ?php
require_once 'Zend/Controller/Action.php';
 
class UserController extends Zend_Controller_Action
{
    public function indexAction()
    {
        $this->_forward('register');
    }
 
    public function loginAction()
    {
    }
 
    public function registerAction()
    {
 
	}
 
    public function lostpasswordAction()
    {
 
    }
 
    public function activateAction()
    {
 
    }
 
    public function profileAction()
    {
 
    }
}

in the index action we just redirect to register action in the same controller. And here – in registerAction() – is where we will concentrate in this part of the series. I want here to have form with the following elements: username, email address, password, password confirmation – these will be required, gender, birth date, real name, checkbox for terms and conditions or agreement – tell it what you want – also required to be checked for registration to take place, and finally – CAPTCHA field.

It is good idea to keep the code, which generates a form separated from the other logic, so several approaches are possible here – to extend Zend_Form class is one obvious way to go, but here I will write getRegistrationForm() method in the UserController class. This method (or member function, I still don’t know which term is better for this :) ) is quite long and have many interesting points, so I will guide you step by step in the process of writing it, instead of just pasting the code and dropping small notes on it. So first we will write small form with just one field (in ‘application/modules/default/controllers/UserController.php‘ we add this function as member function for the class)

private function getRegistrationForm()
{
	$form = new Zend_Form();
 
	$form->setAction('/user/register')->setMethod('post')->setAttrib('id', 'register');
 
	$username = new Zend_Form_Element_Text('username');
	$validatorAlnum = new Zend_Validate_Alnum();
	$username->setRequired(true)->setLabel('Username');
	$form->addElement($username);
 
	$submit = new Zend_Form_Element_Submit('register');
	$submit->setLabel('Submit');
	$form->addElement($submit);
 
	return $form;
}

now in registerAction() we call this getRegistrationForm() function and assign the returned form as property of our View object. Then we check if the request is POST (so the form is submitted) and then call isValid() method of the form object – this method hides the big magic of Zend_Form – with just this simple call we have saved ourselves from typing many tedious lines of code validating the form input, assigning error messages and setting default values to allready filled in fields of the form. I strongly recommend to all of you, who will use Zend_Form for something more serious than just the default behaviour which it provides – to read through isValid() method of Zend_Form and follow the flow of the logic there – validating each of the fields of the form by calling their own isValid() method, which in turn iterates over the validators added to the fields and checks if the value provided is valid. Actually here – in Zend_Form_Element::isValid() – is the place, where the value of the element is stored in the element with setValue() call. So if you want to use getValue() method first you should validate the field (or the whole form). What we also do in registerAction() is to save the values submitted in a property of the View object, so we can verify how our form works (this is just debug code, we will use this in the view template)

    public function registerAction()
    {
        $this->view->registerForm = $this->getRegistrationForm();
        if ($this->getRequest()->isPost())
        {
            if ($this->view->registerForm->isValid($_POST))
            {
                $values = $this->view->registerForm->getValues();
                $this->view->values = $values;
            }
        }
      }

and here is the view template:
application/modules/default/views/scripts/user/register.phtml

<h1>Register:</h1>
&lt; ?php echo $this-&gt;registerForm-&gt;render(); ?&gt;
 
&lt; ?php var_dump($this-&gt;values); ?&gt;

Very simple for now, so lets throw in some more complicated things – filters and validators. But before this we need to have a way to set custom error messages to the validators keeping in mind that we want to make multilangual web site. Zend_Translate will help us with this. I will use gettext translation adapter, because I really don’t like to write the files with messages for translation on my own, and Poedit has the nice feature of parsing source files and gathering strings for translation. So in our bootstrap.php we add:

//we will always use session, so this is good place to create this and save it to the registry
$defSession = new Zend_Session_Namespace('Default', true);
Zend_Registry::set('defSession', $defSession);
 
// Setup translation adapter
// Check if language is set in session - if not - use english as default
$lang = 'en';
if (isset($defSession-&gt;lang))
{
    $lang = $defSession-&gt;lang;
}
$translate = new Zend_Translate('gettext', $siteRootDir . '/languages/'.$lang.'.mo', $lang);
Zend_Registry::set('Zend_Translate', $translate);
Zend_Form::setDefaultTranslator($translate);

So we add to our project new directory – languages/ – where language files will be placed. I want Poedit to parse my source files and autodiscover my strings which need to be translated. When we use validators, for example Zend_Validate_StringLength – we want to set custom error messages. Because for the choosen language to translate to is possible to be different at the time of defining the error messages to the time of showing of the form – we cannot do:

$validatorStringLength = new Zend_Validate_StringLength(3, 32);
        $validatorStringLength-&gt;setMessages(array(
            Zend_Validate_StringLength::TOO_SHORT =&gt; $translate-&gt;_('Your username have to be between 3 and 32 symbols long'),
            Zend_Validate_StringLength::TOO_LONG =&gt; $translate-&gt;_('Your username have to be between 3 and 32 symbols long'),
            )
        );

If we go like this we get the translation at the time of the defining the message, and we DO NOT use the translator which we set for Zend_Form. In fact the translator will be used but the already translated strings will be used as messages to be again translated – and of course they won’t be in the messages database with the translations.
What we want is to have:
Zend_Validate_StringLength::TOO_SHORT => ‘Your username have to be between 3 and 32 symbols long’,
but this way Poedit won’t find our string. So here is my tricky solution to this. I introduce a function __($string) – these are two underscores. So I put in my bootstrap.php (for now, later all this auxiliary functions should go into some library, for now we have just 1 of these):

function __($string)
{
    return $string;
}

So the function just returns its argument.
Now some tips for Poedit. When we create the catalog in the Keywords tab we can add function names, from which to collect messages for translation in addition to the default _() function. Here I add __ – two underscores. Also I have added setLabel, but you can just use the __() function for the purposes of adding Labels to form elements also. Another thing you should do is to add .phtml file extension to the Parser setup for PHP files, so your template files are parsed actually. And finally – add ‘-L php’ to Parser command, so to become ‘xgettext –force-po -o %o %C %K %F -L php’. This prevents nasty error from showing.

Now back to our getRegistrationForm() and lets add the other fields (except the birthdate and the captcha) with desired validators and filters.

&lt; ?php
	private function getRegistrationForm()
    {
        $form = new Zend_Form();
 
        $form-&gt;setAction('/user/register')-&gt;setMethod('post')-&gt;setAttrib('id', 'register');
 
        $filterTrim = new Zend_Filter_StringTrim();
        $validatorNotEmpty = new Zend_Validate_NotEmpty();
        $validatorNotEmpty-&gt;setMessage(__('This field is required, you cannot leave it empty'));
 
        $username = new Zend_Form_Element_Text('username');
        $validatorAlnum = new Zend_Validate_Alnum();
        $validatorAlnum-&gt;setMessage(__('You can use only latin letters and numbers'));
        $validatorStringLength = new Zend_Validate_StringLength(3, 32);
        $validatorStringLength-&gt;setMessages(array(
            Zend_Validate_StringLength::TOO_SHORT =&gt; __('Your username have to be between 3 and 32 symbols long'),
            Zend_Validate_StringLength::TOO_LONG =&gt; __('Your username have to be between 3 and 32 symbols long'),
            )
        );
        $username-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Username')
        -&gt;addFilter($filterTrim)
        -&gt;addValidator($validatorAlnum)
        -&gt;addValidator($validatorStringLength);
        $form-&gt;addElement($username);
 
        /**
         * @todo Change this wired error messages to something more user friendly, or even use simple email regex matching validator
         */
        $email = new Zend_Form_Element_Text('email');
        $validatorHostname = new Zend_Validate_Hostname();
        $validatorHostname-&gt;setMessages(
            array(
                Zend_Validate_Hostname::IP_ADDRESS_NOT_ALLOWED  =&gt; __("'%value%' appears to be an IP address, but IP addresses are not allowed"),
                Zend_Validate_Hostname::UNKNOWN_TLD             =&gt; __("'%value%' appears to be a DNS hostname but cannot match TLD against known list"),
                Zend_Validate_Hostname::INVALID_DASH            =&gt; __("'%value%' appears to be a DNS hostname but contains a dash (-) in an invalid position"),
                Zend_Validate_Hostname::INVALID_HOSTNAME_SCHEMA =&gt; __("'%value%' appears to be a DNS hostname but cannot match against hostname schema for TLD '%tld%'"),
                Zend_Validate_Hostname::UNDECIPHERABLE_TLD      =&gt; __("'%value%' appears to be a DNS hostname but cannot extract TLD part"),
                Zend_Validate_Hostname::INVALID_HOSTNAME        =&gt; __("'%value%' does not match the expected structure for a DNS hostname"),
                Zend_Validate_Hostname::INVALID_LOCAL_NAME      =&gt; __("'%value%' does not appear to be a valid local network name"),
                Zend_Validate_Hostname::LOCAL_NAME_NOT_ALLOWED  =&gt; __("'%value%' appears to be a local network name but local network names are not allowed")
            )
        );
 
        $validatorEmail = new Zend_Validate_EmailAddress(Zend_Validate_Hostname::ALLOW_DNS, false, $validatorHostname);
        $validatorEmail-&gt;setMessages(
            array(
                Zend_Validate_EmailAddress::INVALID            =&gt; __("'%value%' is not a valid email address"),
                Zend_Validate_EmailAddress::INVALID_HOSTNAME   =&gt; __("'%hostname%' is not a valid hostname for email address '%value%'"),
                Zend_Validate_EmailAddress::INVALID_MX_RECORD  =&gt; __("'%hostname%' does not appear to have a valid MX record for the email address '%value%'"),
                Zend_Validate_EmailAddress::DOT_ATOM           =&gt; __("'%localPart%' not matched against dot-atom format"),
                Zend_Validate_EmailAddress::QUOTED_STRING      =&gt; __("'%localPart%' not matched against quoted-string format"),
                Zend_Validate_EmailAddress::INVALID_LOCAL_PART =&gt; __("'%localPart%' is not a valid local part for email address '%value%'")
            )
        );
        $email-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Email Address')
        -&gt;addFilter($filterTrim)
        -&gt;addValidator($validatorEmail);
        $form-&gt;addElement($email);
 
        $password = new Zend_Form_Element_Password('password');
        $password-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Password')
        -&gt;addValidator(new Zend_Validate_StringLength(3));
        $form-&gt;addElement($password);
 
        $password2 = new Zend_Form_Element_Password('password2');
        $password2-&gt;setLabel('Confirm Password');
        $form-&gt;addElement($password2);
 
        $gender = new Zend_Form_Element_Select('gender');
        $gender-&gt;setLabel('Gender')
        -&gt;addMultiOption('',' ')-&gt;addMultiOption('male',__('male'))-&gt;addMultiOption('female',__('female'));
        $form-&gt;addElement($gender);
 
        $realName = new Zend_Form_Element_Text('realname');
        $realName-&gt;setLabel('Real Name')-&gt;addFilter($filterTrim);
        $form-&gt;addElement($realName);
 
        $validatorNotEmptyAgreement = new Zend_Validate_NotEmpty();
        $validatorNotEmptyAgreement-&gt;setMessage(__('You have to accept our terms and conditions before you register'));
        $agreement = new Zend_Form_Element_Checkbox('agreement');
        $agreement-&gt;setLabel('I agree to terms and conditions')
            -&gt;addValidator($validatorNotEmptyAgreement, true)-&gt;setRequired(true);
        $form-&gt;addElement($agreement);
 
        $submit = new Zend_Form_Element_Submit('register');
        $submit-&gt;setLabel('Submit');
        $form-&gt;addElement($submit);
 
        return $form;
    }

Test the form. Everything should work, except the password confirmation field. We need a way to check if password2 is the same as password. So we will write our own validator, extending Zend_Validate_Abstract. We will name our validator My_Validate_PasswordConfirmation, and it will be located in ‘library/My/Validate/PasswordConfirmation.php‘ file.

&lt; ?php
require_once 'Zend/Validate/Abstract.php';   
 
class My_Validate_PasswordConfirmation extends Zend_Validate_Abstract
{
    const NOT_MATCH = 'passwordConfirmationNotMatch';
 
    protected $_messageTemplates = array(
        self::NOT_MATCH =&gt; "Passwords do not match"
    );
 
    protected $fieldToMatch = '';
 
    public function __construct($fieldToMatch)
    {
        $this-&gt;fieldToMatch = (string) $fieldToMatch;
    }
    public function isValid($value, $context = null)
    {
        $valueString = (string) $value;
        $this-&gt;_setValue($valueString);
 
        if (!isset($context[$this-&gt;fieldToMatch]) || $context[$this-&gt;fieldToMatch] !==  $valueString)
        {
            $this-&gt;_error(self::NOT_MATCH);
            return false;
        }
 
        return true;
    }
}

The constructor of the validator has one argument – the name of the field in the form, which value our validating field should match. The isValid() method uses the $context parameter, which is passed by the Zend_Form isValid() method and contains the whole $_POST array. So we just check if the value of our validating field matches the value of the field, given in the constructor. So back in our getRegistrationForm():

        $password2 = new Zend_Form_Element_Password('password2');
        $validatorPassword = new My_Validate_PasswordConfirmation('password');
        $validatorPassword-&gt;setMessage(__('Passwords do not match'));
        $password2-&gt;setLabel('Confirm Password')-&gt;addValidator($validatorPassword);
        $form-&gt;addElement($password2);

and now password confirmation works too.

Two intersting things remain to be done: birthday field and CAPTCHA.
First lets look at the birthday field. We can always use simple text field here, telling the user to enter a date in the format “YYYY-MM-DD”, then validate this with Zend_Validate_Date and hope that our users will understand what we mean with this “YYYY-MM-DD” :) I don’t like this. Another approach is to use some fancy date picker, which fills hidden field with our desired format. This is better. But what I want here is three selects – for day, month and year. But I want this 3 selects to work as one field in the form. So I can tell something like $date = new Zend_Form_Element_Date(‘date’). Well, I use ‘My_’ naming convention for my library code, se the name will be My_Form_Element_DateSelects. In getRegistrationForm(), after our gender field, lets add this:

$date = new My_Form_Element_DateSelects('birthdate');
        $validatorDate = new Zend_Validate_Date();
        $validatorDate-&gt;setMessages(
            array(
                Zend_Validate_Date::NOT_YYYY_MM_DD =&gt; __("'%value%' is not of the format YYYY-MM-DD"),
                Zend_Validate_Date::INVALID        =&gt; __("'%value%' does not appear to be a valid date"),
                Zend_Validate_Date::FALSEFORMAT    =&gt; __("'%value%' does not fit given date format")
            )
        );
        $date-&gt;setLabel('Birthdate')-&gt;addValidator($validatorDate);
        $date-&gt;setShowEmptyValues(true)-&gt;setStartEndYear(1900, date("Y")-7)-&gt;setReverseYears(true);
 
        $form-&gt;addElement($date);

Our custom field will support several options
- showEmpty: if true, will add empty options at the top of the select, so allowing the user not to select any date.
- startYear and endYear – just the range of the year select
- reverseYears – if true – the years will be printed in reverse order.

Here is our My_Form_Element_DateSelects class:
library/My/Form/Element/DateSelects.php

&lt; ?php
require_once 'Zend/Form/Element.php';
 
class My_Form_Element_DateSelects extends Zend_Form_Element_Xhtml
{
    /**
     * Use formSelect view helper by default
     * @var string
     */
    public $helper = 'formDateSelects';    
 
    /**
     * This array will hold options:
     * showEmpty - bool, if true will show and allow empty date
     * startYear, endYear - start and end year to show
     * reverseYears - if true - years will be print from most recent backwards
     *
     * Zend_Form_Decorator_ViewHelper will pass this array as argument to the
     * view helper, responsible for rendering this element
     *
     * @var array
     */
    public $options = array();  
 
    public function init()
    {
        $this-&gt;options['showEmpty'] = true;
        $this-&gt;options['startYear'] = 1900;
        $this-&gt;options['endYear'] = (int) date("Y");
        $this-&gt;options['reverseYears'] = false;
    }
 
    public function setShowEmptyValues($value)
    {
        $this-&gt;options['showEmpty'] = (bool) $value;
        return $this;
    }
 
    public function setStartEndYear($start = null, $end = null)
    {
        if ($start)
        {
            $this-&gt;options['startYear'] = (int) $start;
        }
 
        if ($end)
        {
            $this-&gt;options['endYear'] = (int) $end;
        }
        return $this;
    }
 
    public function setReverseYears($value)
    {
        $this-&gt;options['reverseYears'] = (bool) $value;
        return $this;
    }
 
    /**
     * We want to get the date from our auxiliary fields here
     *
     * @param mixed $value
     * @param mixed $context
     * @return boolean
     */
    public function isValid($value, $context = null)
    {
        $fieldName = $this-&gt;getName();
        $auxiliaryFieldsNames = $this-&gt;getDayMonthYearFieldNames($fieldName);
        if (isset($context[$auxiliaryFieldsNames['day']]) &amp;&amp; isset($context[$auxiliaryFieldsNames['month']])
                &amp;&amp; isset($context[$auxiliaryFieldsNames['year']]))
        {
            if ($context[$auxiliaryFieldsNames['year']] == '-'
                || $context[$auxiliaryFieldsNames['month']] == '-'
                || $context[$auxiliaryFieldsNames['day']] == '-')
            {
                $value = null;
            }
            else
            {
                $value = str_pad($context[$auxiliaryFieldsNames['year']], 4, '0', STR_PAD_LEFT) . '-'
                    . str_pad($context[$auxiliaryFieldsNames['month']], 2, '0', STR_PAD_LEFT) . '-'
                    . str_pad($context[$auxiliaryFieldsNames['day']], 2, '0', STR_PAD_LEFT);
            }
 
            $this-&gt;setValue($value);
        }
 
        return parent::isValid($value, $context);
    }
 
    /**
     * Makes day, month and year names from given element name. Special case is array notation.
     *
     * Given a value such as foo[bar][baz], the generated names will be
     * foo[bar][baz_day], foo[bar][baz_month] and foo[bar][baz_year]
     * I know it is bad design to have this function here and in the View Helper,
     * but I really can't think of other way
     *
     * @param  string $value
     * @return array
     */
    protected function getDayMonthYearFieldNames($value)
    {
        if (empty($value) || !is_string($value)) {
            return $value;
        }
 
        $ret = array(
                'day' =&gt; $value . '_day',
                'month' =&gt; $value . '_month',
                'year' =&gt; $value . '_year'
                );
 
        if (strstr($value, '['))
        {
            $endPos = strlen($value) - 1;
            if (']' != $value[$endPos]) {
                return $ret;
            }
 
            $start = strrpos($value, '[') + 1;
            $name = substr($value, $start, $endPos - $start);
            $arrayName = substr($value, 0, $start-1);
            $ret = array(
                    'day' =&gt; $arrayName . '[' . $name . '_day' . ']',
                    'month' =&gt; $arrayName . '[' . $name . '_month'  . ']',
                    'year' =&gt; $arrayName . '[' . $name . '_year' . ']'
                    );
        }
        return $ret;
    }
}

and the corresponding view helper for rendering it:
library/My/View/Helper/FormDateSelect.php

&lt; ?php
require_once 'Zend/View/Helper/FormElement.php';
 
class My_View_Helper_FormDateSelects extends Zend_View_Helper_FormElement
{
    public function formDateSelects($name, $value = null, $attribs = null,
    $options = null, $listsep = "\n")
    {
        $info = $this-&gt;_getInfo($name, $value, $attribs, $options, $listsep);
        extract($info); // name, id, value, attribs, options, listsep, disable
 
        // now start building the XHTML.
        $disabled = '';
        if (true === $disable) {
            $disabled = ' disabled="disabled"';
        }
 
        $elementNamesArray = $this-&gt;getDayMonthYearFieldNames($name);
        $valueDay = $valueMonth = $valueYear = null;
 
        if ($value !== null)
        {
            $valueExploded = explode('-', $value);
            if (!isset($valueExploded[2]))
            $value = null;
            else
            {
                $valueDay = (int) $valueExploded[2];
                $valueMonth = (int) $valueExploded[1];
                $valueYear = (int) $valueExploded[0];
            }
        }
 
        // Build the surrounding day element first.
        $xhtml = '
<select id="' . $this-&gt;view-&gt;escape($id . '_day') . '" name="' . $this-&gt;view-&gt;escape($elementNamesArray['day']) . '">_htmlAttribs($attribs)
        . "&gt;\n    ";
 
        // build the list of options
        $list = array();
        if ($options['showEmpty'])
        {
            $list[] = '<option value="-"> </option>';
        }
        for ($i = 1; $i &lt; = 31; $i++)
        {
            $list[] = '' . $i . '';
        }
 
        // add the options to the xhtml and close the select
        $xhtml .= implode("\n    ", $list) . "\n</select>
 
";
 
        // Build the month next
        $xhtml .= '
<select id="' . $this-&gt;view-&gt;escape($id . '_month') . '" name="' . $this-&gt;view-&gt;escape($elementNamesArray['month']) . '">_htmlAttribs($attribs)
        . "&gt;\n    ";
 
        // build the list of options
        $list = array();
        if ($options['showEmpty'])
        {
            $list[] = '<option value="-"> </option>';
        }
        for ($i = 1; $i &lt; = 12; $i++)
        {
            $list[] = '' . $i . '';
        }
 
        // add the options to the xhtml and close the select
        $xhtml .= implode("\n    ", $list) . "\n</select>
 
";
 
        // Build the years next
        $xhtml .= '
<select id="' . $this-&gt;view-&gt;escape($id . '_year') . '" name="' . $this-&gt;view-&gt;escape($elementNamesArray['year']) . '">_htmlAttribs($attribs)
        . "&gt;\n    ";
 
        // build the list of options
        $list = array();
        if ($options['showEmpty'])
        {
            $list[] = '<option value="-"> </option>';
        }
 
        if ($options['reverseYears'])
        {
            for ($i = $options['endYear']; $i &gt;= $options['startYear']; $i--)
            {
                $list[] = '<option selected="selected" value="' . $i . '">' . $i . '</option>';
            }
        }
        else
        {
            for ($i = $options['startYear']; $i &gt;= $options['endYear']; $i++)
            {
                $list[] = '<option selected="selected" value="' . $i . '">' . $i . '</option>';
            }
        }
 
        // add the options to the xhtml and close the select
        $xhtml .= implode("\n    ", $list) . "\n</select>
 
";
 
        return $xhtml;
    }
 
    /**
     * Makes day, month and year names from given element name. Special case is array notation.
     *
     * Given a value such as foo[bar][baz], the generated names will be
     * foo[bar][baz_day], foo[bar][baz_month] and foo[bar][baz_year]
     *
     * @param  string $value
     * @return array
     */
    protected function getDayMonthYearFieldNames($value)
    {
        if (empty($value) || !is_string($value)) {
            return $value;
        }
 
        $ret = array(
                'day' =&gt; $value . '_day',
                'month' =&gt; $value . '_month',
                'year' =&gt; $value . '_year'
                );
 
                if (strstr($value, '['))
                {
                    $endPos = strlen($value) - 1;
                    if (']' != $value[$endPos]) {
                        return $ret;
                    }
 
                    $start = strrpos($value, '[') + 1;
                    $name = substr($value, $start, $endPos - $start);
                    $arrayName = substr($value, 0, $start-1);
                    $ret = array(
                    'day' =&gt; $arrayName . '[' . $name . '_day' . ']',
                    'month' =&gt; $arrayName . '[' . $name . '_month'  . ']',
                    'year' =&gt; $arrayName . '[' . $name . '_year' . ']'
                    );
                }
                return $ret;
    }
}

what is interesting is the getDayMonthYearFieldNames() method, which constructs the names for the three “subfields” of our date field. If the name of the data field is array based – we make sure our subfields to have according names. We duplicate the function in both the view helper and the element class itself. May be it is better decision to make this function outside these classes and make it independant, so we avoid the code duplication, but I will go with this design just for now.
In My_Form_Element_DateSelects – isValid() method – we construct the “YYYY-MM-DD” format of the date, getting the auxiliary day, month and year values from the $context (we have all submitted values here). Then we just call parent::isValid() method with our new constructed value.
One last thing remains for this to work: adding the path to our view helper to the view object. This we will do in the bootstrap, so add there:

$view-&gt;addHelperPath('My/View/Helper', 'My_View_Helper_');

just after constructing the view object.

And finally – the CAPTCHA. We will generate the challange text in the user controller and will save it to session. We can show it again if not matched and take it from the session in our captcha controller, so we render the same challange text that we generated :) . The code is quite straightforward. Lets add new controller – CaptchaController.
application/modules/default/controllers/CaptchaController.php

&lt; ?php
 
class CaptchaController extends Zend_Controller_Action
{
    public function init()
    {
        $this-&gt;_helper-&gt;layout-&gt;disableLayout();
        $this-&gt;_helper-&gt;viewRenderer-&gt;setNoRender();
    }
 
    public function getAction()
    {
        $parameters = $this-&gt;_getAllParams();
 
        $width = 150;
        $height = 40; 
 
        $image = ImageCreate($width, $height);
 
        if (isset($parameters['namespace']) &amp;&amp; isset($parameters['captchaId']))
        {
            $session = new Zend_Session_Namespace($parameters['namespace']);
            $text = $session-&gt;{$parameters['captchaId']};
 
            if ($text)
            {
                $grey = ImageColorAllocate($image, 190, 190, 190);
                $black = ImageColorAllocate($image, 0, 0, 0);
                ImageString($image, 20, 50, 15, $text, $black);
            }
        }
 
        $this-&gt;_response-&gt;setHeader('Content-Type', 'image/gif');
        imagegif($image);
    }
}

In init() we disable the layout and tell the view renderer to not render.
We get as parameters the session namespace and the session key where the challange text is saved, then create new GD image, write the text in it, set content type in header to ‘image/gif’ and writing the gif image. Zend_Controller_Dispatcher_Standard which we use implicitly uses output buffering, so this gif image is captured as response body, and sent to the client from our bootstrap.php file.
To show this captcha image on our form we will use decorator, so we write
library/My/Form/Decorator/Captcha.php‘:

&lt; ?php
 
require_once 'Zend/Form/Decorator/Abstract.php';
 
/**
 * My_Form_Decorator_Captcha
 *
 * Shows CAPTCHA image
 *
 * Options that must be provided are:
 * - namespace: The id of the captcha to show (the key in the session namespace, where the value is saved)
 * - captchaId: The id of the captcha to show (the key in the session namespace, where the value is saved)
 * - tag: tag to use in decorator
 */
class My_Form_Decorator_Captcha extends Zend_Form_Decorator_Abstract
{
    /**
     * Default placement: append
     * @var string
     */
    protected $_placement = 'PREPEND';
 
    /**
     * HTML tag with which to surround image
     * @var string
     */
    protected $_tag;   
 
    /**
     * Set HTML tag with which to surround image
     *
     * @param  string $tag
     * @return My_Form_Decorator_Captcha
     */
    public function setTag($tag)
    {
        $this-&gt;_tag = (string) $tag;
        return $this;
    }
 
    /**
     * Get HTML tag, if any, with which to surround image
     *
     * @return void
     */
    public function getTag()
    {
        if (null === $this-&gt;_tag) {
            $tag = $this-&gt;getOption('tag');
            if (null !== $tag) {
                $this-&gt;removeOption('tag');
                $this-&gt;setTag($tag);
            }
            return $tag;
        }
 
        return $this-&gt;_tag;
    }
 
    /**
     * Render a captcha image
     *
     * @param  string $content
     * @return string
     */
    public function render($content)
    {
        $element = $this-&gt;getElement();
        $view    = $element-&gt;getView();
        if (null === $view) {
            return $content;
        }
 
        $tag       = $this-&gt;getTag();
        $placement = $this-&gt;getPlacement();
        $separator = $this-&gt;getSeparator();
 
        $namespace = $this-&gt;getOption('namespace');
        $captchaId = $this-&gt;getOption('captchaId');
 
        if (!$namespace || !$captchaId)
        {
            require_once ('Zend/Form/Decorator/Exception.php');
            $exception = new Zend_Form_Decorator_Exception('namespace or captchaId not set');
            throw $exception;
        }
 
        $image = '<img src="/captcha/get/'.$namespace.'/'.$captchaId.'" alt="CAPTCHA challange" />'; 
 
        if (null !== $tag) {
            require_once 'Zend/Form/Decorator/HtmlTag.php';
            $decorator = new Zend_Form_Decorator_HtmlTag();
            $decorator-&gt;setOptions(array('tag' =&gt; $tag));
            $image = $decorator-&gt;render($image);
        }
 
        switch ($placement) {
            case self::PREPEND:
                return $image . $separator . $content;
            case self::APPEND:
            default:
                return $content . $separator . $image;
        }
    }
}

As template for this decorator I have used Zend_Form_Decorator_Image, so there are snippets of code taken from there.
In order to use shorter URLs like /captcha/get/[namespace]/[captchaId], and not like /captcha/get/namespace/[namespace]/captchaId/[captchaId] we need to define custom route in our router, so in our bootstrap.php:

// define some routes (URLs)
$router = $frontController-&gt;getRouter();
$captchaRoute = new Zend_Controller_Router_Route('captcha/get/:namespace/:captchaId',
    array('controller'=&gt;'captcha', 'action'=&gt;'get')
    );
$router-&gt;addRoute('captcha', $captchaRoute);

and now add the captcha field to the form:
in getRegistrationForm():

if (!isset($this-&gt;session-&gt;passedRegisterCaptcha) || !$this-&gt;session-&gt;passedRegisterCaptcha)
        {
            //if we have set captcha in the session for this request - use it, else generate new one
            if (isset($this-&gt;session-&gt;registerCaptcha))
            {
                $captchaCode = $this-&gt;session-&gt;registerCaptcha;
            }
            else
            {
                $md5Hash = md5($_SERVER['REQUEST_TIME']);
                $captchaCode = substr($md5Hash, rand(0, 25), 5);
                $this-&gt;session-&gt;registerCaptcha = $captchaCode ;
            }
 
            $captcha = new Zend_Form_Element_Text('captcha');
            $captcha-&gt;setLabel('Enter the text')
                -&gt;addValidator(new Zend_Validate_Identical($captchaCode))
                -&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true);
            $captchaDecorator = new My_Form_Decorator_Captcha();
            $captchaDecorator-&gt;setOption('namespace', 'User')-&gt;setOption('captchaId', 'registerCaptcha');
            $captchaDecorator-&gt;setTag('div');
            $captcha-&gt;addDecorator($captchaDecorator);
            $form-&gt;addElement($captcha);
        }

so here is the whole UserContoller.php:

&lt; ?
require_once 'Zend/Controller/Action.php';
 
class UserController extends Zend_Controller_Action
{
    /**
     * Session namespace object for user data
     *
     * @var Zend_Session_Namespace
     */
    public $session = null;
 
    public function init()
    {
        $this-&gt;session = new Zend_Session_Namespace('User');
        $this-&gt;view-&gt;translate = Zend_Registry::get('Zend_Translate');
    }
 
    public function indexAction()
    {
        $this-&gt;_forward('register');
    }
 
    public function loginAction()
    {
    }
 
    public function registerAction()
    {
        $this-&gt;view-&gt;registerForm = $this-&gt;getRegistrationForm();
        if ($this-&gt;getRequest()-&gt;isPost())
        {
            if ($this-&gt;view-&gt;registerForm-&gt;isValid($_POST))
            {
                $values = $this-&gt;view-&gt;registerForm-&gt;getValues();
                $this-&gt;view-&gt;values = $values;
            }
 
            //we want to know if we have passed the captcha, so won't show it again:
            if (!isset($this-&gt;session-&gt;passedRegisterCaptcha) || !$this-&gt;session-&gt;passedRegisterCaptcha)
            {
                $captchaField = $this-&gt;view-&gt;registerForm-&gt;captcha;
                if ($captchaField-&gt;isValid($captchaField-&gt;getValue()))
                {
                    $this-&gt;session-&gt;passedRegisterCaptcha = true;
                    $this-&gt;view-&gt;registerForm-&gt;removeElement('captcha');
                }
            }
 
        }
    }
 
    public function lostpasswordAction()
    {
 
    }
 
    public function activateAction()
    {
 
    }
 
    public function profileAction()
    {
 
    }
 
    private function getRegistrationForm()
    {
        $form = new Zend_Form();
 
        $form-&gt;setAction('/user/register')-&gt;setMethod('post')-&gt;setAttrib('id', 'register');
 
        $filterTrim = new Zend_Filter_StringTrim();
        $validatorNotEmpty = new Zend_Validate_NotEmpty();
        $validatorNotEmpty-&gt;setMessage(__('This field is required, you cannot leave it empty'));
 
        $username = new Zend_Form_Element_Text('username');
        $validatorAlnum = new Zend_Validate_Alnum();
        $validatorAlnum-&gt;setMessage(__('You can use only latin letters and numbers'));
        $validatorStringLength = new Zend_Validate_StringLength(3, 32);
        $validatorStringLength-&gt;setMessages(array(
            Zend_Validate_StringLength::TOO_SHORT =&gt; __('Your username have to be between 3 and 32 symbols long'),
            Zend_Validate_StringLength::TOO_LONG =&gt; __('Your username have to be between 3 and 32 symbols long'),
            )
        );
        $username-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Username')
        -&gt;addFilter($filterTrim)
        -&gt;addValidator($validatorAlnum)
        -&gt;addValidator($validatorStringLength);
        $form-&gt;addElement($username);
 
        /**
         * @todo Change this wired error messages to something more user friendly, or even use simple email regex matching validator
         */
        $email = new Zend_Form_Element_Text('email');
        $validatorHostname = new Zend_Validate_Hostname();
        $validatorHostname-&gt;setMessages(
            array(
                Zend_Validate_Hostname::IP_ADDRESS_NOT_ALLOWED  =&gt; __("'%value%' appears to be an IP address, but IP addresses are not allowed"),
                Zend_Validate_Hostname::UNKNOWN_TLD             =&gt; __("'%value%' appears to be a DNS hostname but cannot match TLD against known list"),
                Zend_Validate_Hostname::INVALID_DASH            =&gt; __("'%value%' appears to be a DNS hostname but contains a dash (-) in an invalid position"),
                Zend_Validate_Hostname::INVALID_HOSTNAME_SCHEMA =&gt; __("'%value%' appears to be a DNS hostname but cannot match against hostname schema for TLD '%tld%'"),
                Zend_Validate_Hostname::UNDECIPHERABLE_TLD      =&gt; __("'%value%' appears to be a DNS hostname but cannot extract TLD part"),
                Zend_Validate_Hostname::INVALID_HOSTNAME        =&gt; __("'%value%' does not match the expected structure for a DNS hostname"),
                Zend_Validate_Hostname::INVALID_LOCAL_NAME      =&gt; __("'%value%' does not appear to be a valid local network name"),
                Zend_Validate_Hostname::LOCAL_NAME_NOT_ALLOWED  =&gt; __("'%value%' appears to be a local network name but local network names are not allowed")
            )
        );
 
        $validatorEmail = new Zend_Validate_EmailAddress(Zend_Validate_Hostname::ALLOW_DNS, false, $validatorHostname);
        $validatorEmail-&gt;setMessages(
            array(
                Zend_Validate_EmailAddress::INVALID            =&gt; __("'%value%' is not a valid email address"),
                Zend_Validate_EmailAddress::INVALID_HOSTNAME   =&gt; __("'%hostname%' is not a valid hostname for email address '%value%'"),
                Zend_Validate_EmailAddress::INVALID_MX_RECORD  =&gt; __("'%hostname%' does not appear to have a valid MX record for the email address '%value%'"),
                Zend_Validate_EmailAddress::DOT_ATOM           =&gt; __("'%localPart%' not matched against dot-atom format"),
                Zend_Validate_EmailAddress::QUOTED_STRING      =&gt; __("'%localPart%' not matched against quoted-string format"),
                Zend_Validate_EmailAddress::INVALID_LOCAL_PART =&gt; __("'%localPart%' is not a valid local part for email address '%value%'")
            )
        );
        $email-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Email Address')
        -&gt;addFilter($filterTrim)
        -&gt;addValidator($validatorEmail);
        $form-&gt;addElement($email);
 
        $password = new Zend_Form_Element_Password('password');
        $password-&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true)-&gt;setLabel('Password')
        -&gt;addValidator(new Zend_Validate_StringLength(3));
        $form-&gt;addElement($password);
 
        $password2 = new Zend_Form_Element_Password('password2');
        $validatorPassword = new My_Validate_PasswordConfirmation('password');
        $validatorPassword-&gt;setMessage(__('Passwords do not match'));
        $password2-&gt;setLabel('Confirm Password')-&gt;addValidator($validatorPassword);
        $form-&gt;addElement($password2);
 
        $gender = new Zend_Form_Element_Select('gender');
        $gender-&gt;setLabel('Gender')
        -&gt;addMultiOption('',' ')-&gt;addMultiOption('male',__('male'))-&gt;addMultiOption('female',__('female'));
        $form-&gt;addElement($gender);
 
        $date = new My_Form_Element_DateSelects('birthdate');
        $validatorDate = new Zend_Validate_Date();
        $validatorDate-&gt;setMessages(
            array(
                Zend_Validate_Date::NOT_YYYY_MM_DD =&gt; __("'%value%' is not of the format YYYY-MM-DD"),
                Zend_Validate_Date::INVALID        =&gt; __("'%value%' does not appear to be a valid date"),
                Zend_Validate_Date::FALSEFORMAT    =&gt; __("'%value%' does not fit given date format")
            )
        );
        $date-&gt;setLabel('Birthdate')-&gt;addValidator($validatorDate);
        $date-&gt;setShowEmptyValues(true)-&gt;setStartEndYear(1900, date("Y")-7)-&gt;setReverseYears(true);
 
        $form-&gt;addElement($date);
 
        $realName = new Zend_Form_Element_Text('realname');
        $realName-&gt;setLabel('Real Name')-&gt;addFilter($filterTrim);
        $form-&gt;addElement($realName);
 
        $validatorNotEmptyAgreement = new Zend_Validate_NotEmpty();
        $validatorNotEmptyAgreement-&gt;setMessage(__('You have to accept our terms and conditions before you register'));
        $agreement = new Zend_Form_Element_Checkbox('agreement');
        $agreement-&gt;setLabel('I agree to terms and conditions')
            -&gt;addValidator($validatorNotEmptyAgreement, true)-&gt;setRequired(true);
        $form-&gt;addElement($agreement);
 
        if (!isset($this-&gt;session-&gt;passedRegisterCaptcha) || !$this-&gt;session-&gt;passedRegisterCaptcha)
        {
            //if we have set captcha in the session for this request - use it, else generate new one
            if (isset($this-&gt;session-&gt;registerCaptcha))
            {
                $captchaCode = $this-&gt;session-&gt;registerCaptcha;
            }
            else
            {
                $md5Hash = md5($_SERVER['REQUEST_TIME']);
                $captchaCode = substr($md5Hash, rand(0, 25), 5);
                $this-&gt;session-&gt;registerCaptcha = $captchaCode ;
            }
 
            $captcha = new Zend_Form_Element_Text('captcha');
            $captcha-&gt;setLabel('Enter the text')
                -&gt;addValidator(new Zend_Validate_Identical($captchaCode))
                -&gt;addValidator($validatorNotEmpty, true)-&gt;setRequired(true);
            $captchaDecorator = new My_Form_Decorator_Captcha();
            $captchaDecorator-&gt;setOption('namespace', 'User')-&gt;setOption('captchaId', 'registerCaptcha');
            $captchaDecorator-&gt;setTag('div');
            $captcha-&gt;addDecorator($captchaDecorator);
            $form-&gt;addElement($captcha);
        }
 
        $submit = new Zend_Form_Element_Submit('register');
        $submit-&gt;setLabel('Submit');
        $form-&gt;addElement($submit);
 
        return $form;
    }
}

and the whole bootstrap.php:

&lt; ?php
 
// For our dev environment we will report all errors to the screen
error_reporting(E_ALL | E_STRICT);
ini_set('display_startup_errors', 1);
ini_set('display_errors', 1);
 
// Set our timezone
date_default_timezone_set('Europe/Sofia');   
 
// Add /library and /application directory to our include path
$siteRootDir = dirname($_SERVER['DOCUMENT_ROOT']);
 
set_include_path(
    $siteRootDir . '/library' . PATH_SEPARATOR
    . $siteRootDir . '/application' . PATH_SEPARATOR
    . get_include_path()
);
 
// Turn on autoloading, so we do not include each Zend Framework class
require_once 'Zend/Loader.php';
Zend_Loader::registerAutoload();
 
// Create registry object and setting it as the static instance in the Zend_Registry class
$registry = new Zend_Registry();
Zend_Registry::setInstance($registry);
 
// Load configuration file and store the data in the registry
$configuration = new Zend_Config_Ini($siteRootDir . '/configuration/config.ini', 'main');
Zend_Registry::set('configuration', $configuration);
 
// Construct the database adapter class, connect to the database and store the db object in the registry
$db = Zend_Db::factory($configuration-&gt;db);
$db-&gt;query("SET NAMES 'utf8'");
Zend_Registry::set('db', $db);
// set this adapter as default for use with Zend_Db_Table
Zend_Db_Table_Abstract::setDefaultAdapter($db);
 
// Now set session save handler to our custom class which saves the data in MySQL database
$sessionManager = new My_Session_Manager();
Zend_Session::setOptions(array(
    'gc_probability' =&gt; 1,
    'gc_divisor' =&gt; 5000
    ));
Zend_Session::setSaveHandler($sessionManager);
 
//we will always use session, so this is good place to create this and save it to the registry
$defSession = new Zend_Session_Namespace('Default', true);
Zend_Registry::set('defSession', $defSession);
 
// Setup translation adapter
// Check if language is set in session - if not - use english as default
$lang = 'en';
if (isset($defSession-&gt;lang))
{
    $lang = $defSession-&gt;lang;
}
$translate = new Zend_Translate('gettext', $siteRootDir . '/languages/'.$lang.'.mo', $lang);
Zend_Registry::set('Zend_Translate', $translate);
Zend_Form::setDefaultTranslator($translate);
 
// Setup the Front Controller, disable the error handler, set our controller directories
$frontController = Zend_Controller_Front::getInstance();
$frontController-&gt;throwExceptions(true);
$frontController-&gt;addModuleDirectory($siteRootDir . '/application/modules');
//we want the front controller to return the response, instead of emitting it automatically
$frontController-&gt;returnResponse(true);
 
// define some routes (URLs)
$router = $frontController-&gt;getRouter();
$captchaRoute = new Zend_Controller_Router_Route('captcha/get/:namespace/:captchaId',
    array('controller'=&gt;'captcha', 'action'=&gt;'get')
    );
$router-&gt;addRoute('captcha', $captchaRoute);
 
/*
 * We want to set the encoding to UTF-8, so we won't rely on the ViewRenderer action helper by default,
 * but will construct view object and deliver it to the ViewRenderer after setting some options.
 */
$view = new Zend_View(array('encoding'=&gt;'UTF-8'));
//$view-&gt;addHelperPath($siteRootDir . '/library/My/View/Helper', 'My_View_Helper');
$view-&gt;addHelperPath('My/View/Helper', 'My_View_Helper_');
$viewRendered = new Zend_Controller_Action_Helper_ViewRenderer($view);
Zend_Controller_Action_HelperBroker::addHelper($viewRendered);
 
// Now we initialize the Zend_Layout object with MVC support
Zend_Layout::startMvc(
    array(
        'layoutPath' =&gt; $siteRootDir . '/application/layouts',
        'layout' =&gt; 'main'
    )
);
 
// run the dispatch, get the response and send it to the client
$response = $frontController-&gt;dispatch();
$response-&gt;sendResponse();
 
function __($string)
{
    return $string;
}

A lot of new stuff we covered today, here is the part4.rar file with the current version of the project.

Share and Enjoy:
  • Digg
  • Sphinn
  • del.icio.us
  • Facebook
  • Mixx
  • Google Bookmarks
  • Fark
  • Furl
  • Live
  • Ma.gnolia
  • Reddit
  • Spurl
  • Technorati

Building Sample Web Site Based On Zend Framework Series
Zend Framework

Comments (38)

Permalink

Part 3 – making sessions work with database using Zend_Db_Table

Please, note that this text is written in May 2008. Since then Zend Framework have evolved and now provides Zend_Application class. It is good idea to use it. The user registration and login stuff, presented here is still actual though. Also there are many other ideas presented, which I hope will be usefull for the readers.

Before we start with the new stuff for today – we should fix what we have found to be wrong in the first 2 parts :) In our bootstrap.php file just before sending the response to the client we set a header:

$response-&gt;setHeader('Content-Type', 'text/html; charset=UTF-8', true);

Thanks to Luke Richards who pointed that if we later want to return different content – JSON, XML or something we are stuck with this header. In fact having

 

in the layout template is enough to make sure our content is displayed correctly in the client browser, so we just drop the

$response-&gt;setHeader('Content-Type', 'text/html; charset=UTF-8', true);

line from our bootstrap.php.

Now to the new stuff – as planned earlier now I want to implement session handler, which works with MySQL database and not with files. Two are the main reasons we want this – security and scalability. The security part – when in files, anyone with access to the webserver filesystem can steal or even manipulate session data. And while this is not an issue when we manage our own web server – the other reason – scalability applies strong here. If we ever need to have two or more webservers serving our application because of the great amount of visitors we have – we will need to have a solution for the sessions. Sticky sessions are one solution to this, but not guarantee to us that the servers will be equally loaded. Zend are offering session management in their application server – this is another solution. Storing the sessions in common cache cluster – Memcached for example – is also an option. We will take different approach and just put our session data in the database.
session_set_save_handler function makes this possible.

We will need to construct a table for the session info – here is the SQL statement for it:

CREATE TABLE `session` (
  `session_id` varchar(32) NOT NULL,
  `session_data` text NOT NULL,
  `t_created` datetime NOT NULL,
  `t_updated` datetime NOT NULL,
  PRIMARY KEY  (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

In t_created field we will save the timestamp when the session is first created and in t_updated we will save the timestamp when the session is last updated (last accessed).
EDIT: (My_Session_Manager used to be with static functions and we used direct session_set_save_handler function.)
To comply with Zend Framework convnetions we will write a class My_Session_Manager which implements Zend_Session_SaveHandler_Interface – defining functions for open, close, read, write, destroy and gc handlers. If we want our class to be loadable from Zend_Loader we should follow Zend naming convention, so we create directory ‘library/My/Session‘ and put in there file called Manager.php, so:
library/My/Session/Manager.php

&lt; ?php
 
require_once 'Zend/Session/SaveHandler/Interface.php';
 
class My_Session_Manager implements Zend_Session_SaveHandler_Interface
{
    /**
     * This is instance of My_Session_data, which extends Zend_Db_Table and manages the database connection
     *
     * @var My_Session_Data
     */
    private static $sessionData;
 
    private static $thisIsOldSession = false;
	private static $originalSessionId = '';
 
    public function open($save_path, $name)
    {
        self::$sessionData = new My_Session_Data();
        return true;
    }
 
    public function close()
    {
 
        return true;
    }
 
    public function read($id)
    {
        $rows = self::$sessionData-&gt;find($id);
        $row = $rows-&gt;current();
        if ($row)
        {
            self::$thisIsOldSession = true;
			self::$originalSessionId = $id;
            return $row-&gt;session_data;
        }
        else
        {
            return '';
        }
    }
 
    public function write($id, $sessionData)
    {
        $data = array
        (
            'session_data' =&gt; $sessionData,
            't_updated' =&gt; new Zend_Db_Expr('NOW()'),
        );
 
		if (self::$thisIsOldSession &amp;&amp; self::$originalSessionId != $id)
        {
            // session ID is regenerated, so set $thisIsOldSession to false, so we insert new row
            self::$thisIsOldSession = false;
        }
 
        if (self::$thisIsOldSession)
        {
            self::$sessionData-&gt;update
            (
            $data,
            self::$sessionData-&gt;getAdapter()-&gt;quoteInto('session_id = ?', $id)
            );
        }
        else
        {
            //no such session, create new one
            $data['session_id'] = $id;
            $data['t_created'] = new Zend_Db_Expr('NOW()');
            self::$sessionData-&gt;insert($data);
        }
 
        return true;
    }
 
    public function destroy($id)
    {
        self::$sessionData-&gt;delete(self::$sessionData-&gt;getAdapter()-&gt;quoteInto('session_id = ?', $id));
        return true;
    }
 
    public function gc($maxLifetime)
    {
        $maxLifetime = intval($maxLifetime);
        self::$sessionData-&gt;delete("DATE_ADD(t_updated, INTERVAL $maxLifetime SECOND) &lt; NOW()");
        return true;
    }
}

and may be you have noticed we here use class My_Sessoin_Data – this is very simple class indeed – look at its declaration:

library/My/Session/Data.php‘:

&lt; ?php
 
class My_Session_Data extends Zend_Db_Table_Abstract
{
    protected $_name = 'session';
}

so, what’s happening here? My_Session_Data extends Zend_Db_Table_Abstract and we use it to access our sessions table in our database. My_Session_Manager defines several static methods – our session handlers. They use the functionality provided by Zend_Db_Table to perform the SQL commands to the database, I won’t explain in details, because in Zend_DB chapter in the Reference Guide this is explained better than I could do it :)
open() just creates new object from My_Session_Data and saves it to private member variable of the class. close() removes the reference to the My_Session_Data object just returns true. read() is also very simple – we search in the database table for row with the id of the current session. If we find such – we return the data written there. Else – we return empty string. write() is the most complex method here, although not big deal either – we construct $data array, holding the data we want to update in the database table and then we call the update() method of My_Session_Data.

self::$sessionData-&gt;getAdapter()-&gt;quoteInto('session_id = ?', $id)

is quite clumsy way to state that we want to update the row where session_id is equal to $id, but I couldn’t think of shorter and better :( – If someone have some better idea I’ll be happy to hear it.
If there is no updated row, then we do not have such session – then we just create it. We add to the $data array info about the primary key – session_id and when the session is created – NOW(). BUGFIX here – in the old code we checked for the number of updated rows from the update query, which we ran every time – and if 0 rows were updated we inserted the new session. But if the session is in fact old and saved again in the same second, so ‘t_updated’ is still the same – then 0 rows are updated in spite of being old session. So we have a fix here – we introduce static variable $thisIsOldSession which is set to true in read() if session is found – then in write() we use this to determine if we should do update or insert.
destroy() should delete a session, so we delete the row in the database about it. And finally – the garbage collector gc() – here we delete all rows that are not updated more than $maxLifetime seconds. And that’s all. So now how we make that php uses our class for session handling?
We go back in our bootstrap.php file, and right after we have initialized the database connection (we need it for the session management) we add some code, telling the Zend_Session to use our just built session manager class:

// Construct the database adapter class, connect to the database and store the db object in the registry
$db = Zend_Db::factory($configuration-&gt;db);
$db-&gt;query("SET NAMES 'utf8'");
Zend_Registry::set('db', $db);
// set this adapter as default for use with Zend_Db_Table
Zend_Db_Table_Abstract::setDefaultAdapter($db);
 
// Now set session save handler to our custom class which saves the data in MySQL database
$sessionManager = new My_Session_Manager();
Zend_Session::setOptions(array(
    'gc_probability' =&gt; 1,
    'gc_divisor' =&gt; 5000
    ));
Zend_Session::setSaveHandler($sessionManager);

We can fine tune the garbage collector, but I think 1 in 5000 page hits is good enough. The value of 1440 seconds for max lifetime of sessions is the default one – we can change this too if needed.

Now it _should_ work. Let’s test it. Create new action controller:
application/modules/default/controllers/TestController.php‘:

&lt; ?php
 
class TestController extends Zend_Controller_Action
{
    public function indexAction()
    {
        $defaultNamespace = new Zend_Session_Namespace('Default');
        if (isset($defaultNamespace-&gt;numberOfPageRequests)) {
            $defaultNamespace-&gt;numberOfPageRequests++; // this will increment for each page load.
        }
        else
        {
            $defaultNamespace-&gt;numberOfPageRequests = 1; // first time
        }
 
        $this-&gt;view-&gt;xxx = $defaultNamespace-&gt;numberOfPageRequests;
    }
}

and template script for the index action:
application/modules/default/views/scripts/test/index.phtml‘:

&lt; ?php echo "Page requests this session: ", $this-&gt;xxx; ?&gt;

Now when we reload several times ‘http://localhost/test’ we see that our session handler works.

See you again in Part 4. Here is the code so far: part3.rar

Share and Enjoy:
  • Digg
  • Sphinn
  • del.icio.us
  • Facebook
  • Mixx
  • Google Bookmarks
  • Fark
  • Furl
  • Live
  • Ma.gnolia
  • Reddit
  • Spurl
  • Technorati

Building Sample Web Site Based On Zend Framework Series
Zend Framework

Comments (35)

Permalink