Part 4 - Zend_Form, CAPTCHA, Password Confirmation, Date selector field, Zend_Translate
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> < ?php echo $this->registerForm->render(); ?> < ?php var_dump($this->values); ?>
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->lang)) { $lang = $defSession->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->setMessages(array( Zend_Validate_StringLength::TOO_SHORT => $translate->_('Your username have to be between 3 and 32 symbols long'), Zend_Validate_StringLength::TOO_LONG => $translate->_('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.
< ?php private function getRegistrationForm() { $form = new Zend_Form(); $form->setAction('/user/register')->setMethod('post')->setAttrib('id', 'register'); $filterTrim = new Zend_Filter_StringTrim(); $validatorNotEmpty = new Zend_Validate_NotEmpty(); $validatorNotEmpty->setMessage(__('This field is required, you cannot leave it empty')); $username = new Zend_Form_Element_Text('username'); $validatorAlnum = new Zend_Validate_Alnum(); $validatorAlnum->setMessage(__('You can use only latin letters and numbers')); $validatorStringLength = new Zend_Validate_StringLength(3, 32); $validatorStringLength->setMessages(array( Zend_Validate_StringLength::TOO_SHORT => __('Your username have to be between 3 and 32 symbols long'), Zend_Validate_StringLength::TOO_LONG => __('Your username have to be between 3 and 32 symbols long'), ) ); $username->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Username') ->addFilter($filterTrim) ->addValidator($validatorAlnum) ->addValidator($validatorStringLength); $form->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->setMessages( array( Zend_Validate_Hostname::IP_ADDRESS_NOT_ALLOWED => __("'%value%' appears to be an IP address, but IP addresses are not allowed"), Zend_Validate_Hostname::UNKNOWN_TLD => __("'%value%' appears to be a DNS hostname but cannot match TLD against known list"), Zend_Validate_Hostname::INVALID_DASH => __("'%value%' appears to be a DNS hostname but contains a dash (-) in an invalid position"), Zend_Validate_Hostname::INVALID_HOSTNAME_SCHEMA => __("'%value%' appears to be a DNS hostname but cannot match against hostname schema for TLD '%tld%'"), Zend_Validate_Hostname::UNDECIPHERABLE_TLD => __("'%value%' appears to be a DNS hostname but cannot extract TLD part"), Zend_Validate_Hostname::INVALID_HOSTNAME => __("'%value%' does not match the expected structure for a DNS hostname"), Zend_Validate_Hostname::INVALID_LOCAL_NAME => __("'%value%' does not appear to be a valid local network name"), Zend_Validate_Hostname::LOCAL_NAME_NOT_ALLOWED => __("'%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->setMessages( array( Zend_Validate_EmailAddress::INVALID => __("'%value%' is not a valid email address"), Zend_Validate_EmailAddress::INVALID_HOSTNAME => __("'%hostname%' is not a valid hostname for email address '%value%'"), Zend_Validate_EmailAddress::INVALID_MX_RECORD => __("'%hostname%' does not appear to have a valid MX record for the email address '%value%'"), Zend_Validate_EmailAddress::DOT_ATOM => __("'%localPart%' not matched against dot-atom format"), Zend_Validate_EmailAddress::QUOTED_STRING => __("'%localPart%' not matched against quoted-string format"), Zend_Validate_EmailAddress::INVALID_LOCAL_PART => __("'%localPart%' is not a valid local part for email address '%value%'") ) ); $email->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Email Address') ->addFilter($filterTrim) ->addValidator($validatorEmail); $form->addElement($email); $password = new Zend_Form_Element_Password('password'); $password->addValidator($validatorNotEmpty, true)->setRequired(true)->setLabel('Password') ->addValidator(new Zend_Validate_StringLength(3)); $form->addElement($password); $password2 = new Zend_Form_Element_Password('password2'); $password2->setLabel('Confirm Password'); $form->addElement($password2); $gender = new Zend_Form_Element_Select('gender'); $gender->setLabel('Gender') ->addMultiOption('',' ')->addMultiOption('male',__('male'))->addMultiOption('female',__('female')); $form->addElement($gender); $realName = new Zend_Form_Element_Text('realname'); $realName->setLabel('Real Name')->addFilter($filterTrim); $form->addElement($realName); $validatorNotEmptyAgreement = new Zend_Validate_NotEmpty(); $validatorNotEmptyAgreement->setMessage(__('You have to accept our terms and conditions before you register')); $agreement = new Zend_Form_Element_Checkbox('agreement'); $agreement->setLabel('I agree to terms and conditions') ->addValidator($validatorNotEmptyAgreement, true)->setRequired(true); $form->addElement($agreement); $submit = new Zend_Form_Element_Submit('register'); $submit->setLabel('Submit'); $form->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.
< ?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 => "Passwords do not match" ); protected $fieldToMatch = ''; public function __construct($fieldToMatch) { $this->fieldToMatch = (string) $fieldToMatch; } public function isValid($value, $context = null) { $valueString = (string) $value; $this->_setValue($valueString); if (!isset($context[$this->fieldToMatch]) || $context[$this->fieldToMatch] !== $valueString) { $this->_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->setMessage(__('Passwords do not match')); $password2->setLabel('Confirm Password')->addValidator($validatorPassword); $form->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->setMessages( array( Zend_Validate_Date::NOT_YYYY_MM_DD => __("'%value%' is not of the format YYYY-MM-DD"), Zend_Validate_Date::INVALID => __("'%value%' does not appear to be a valid date"), Zend_Validate_Date::FALSEFORMAT => __("'%value%' does not fit given date format") ) ); $date->setLabel('Birthdate')->addValidator($validatorDate); $date->setShowEmptyValues(true)->setStartEndYear(1900, date("Y")-7)->setReverseYears(true); $form->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‘
< ?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->options['showEmpty'] = true; $this->options['startYear'] = 1900; $this->options['endYear'] = (int) date("Y"); $this->options['reverseYears'] = false; } public function setShowEmptyValues($value) { $this->options['showEmpty'] = (bool) $value; return $this; } public function setStartEndYear($start = null, $end = null) { if ($start) { $this->options['startYear'] = (int) $start; } if ($end) { $this->options['endYear'] = (int) $end; } return $this; } public function setReverseYears($value) { $this->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->getName(); $auxiliaryFieldsNames = $this->getDayMonthYearFieldNames($fieldName); if (isset($context[$auxiliaryFieldsNames['day']]) && isset($context[$auxiliaryFieldsNames['month']]) && 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->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' => $value . '_day', 'month' => $value . '_month', 'year' => $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' => $arrayName . '[' . $name . '_day' . ']', 'month' => $arrayName . '[' . $name . '_month' . ']', 'year' => $arrayName . '[' . $name . '_year' . ']' ); } return $ret; } }
and the corresponding view helper for rendering it:
‘library/My/View/Helper/FormDateSelect.php‘
< ?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 = "<br />\n") { $info = $this->_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->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 ' . ' name="' . $this->view->escape($elementNamesArray['day']) . '"' . ' id="' . $this->view->escape($id . '_day') . '"' . $disabled . $this->_htmlAttribs($attribs) . ">\n "; // build the list of options $list = array(); if ($options['showEmpty']) { $list[] = '<option value="-"> </option>'; } for ($i = 1; $i < = 31; $i++) { $list[] = '<option' . ' value="' . $i . '"' . ($valueDay === $i ? ' selected="selected"' : '') . '>' . $i . ''; } // add the options to the xhtml and close the select $xhtml .= implode("\n ", $list) . "\n</select>"; // Build the month next $xhtml .= ' <select ' . ' name="' . $this->view->escape($elementNamesArray['month']) . '"' . ' id="' . $this->view->escape($id . '_month') . '"' . $disabled . $this->_htmlAttribs($attribs) . ">\n "; // build the list of options $list = array(); if ($options['showEmpty']) { $list[] = '<option value="-"> </option>'; } for ($i = 1; $i < = 12; $i++) { $list[] = '<option' . ' value="' . $i . '"' . ($valueMonth === $i ? ' selected="selected"' : '') . '>' . $i . ''; } // add the options to the xhtml and close the select $xhtml .= implode("\n ", $list) . "\n</select>"; // Build the years next $xhtml .= ' <select ' . ' name="' . $this->view->escape($elementNamesArray['year']) . '"' . ' id="' . $this->view->escape($id . '_year') . '"' . $disabled . $this->_htmlAttribs($attribs) . ">\n "; // build the list of options $list = array(); if ($options['showEmpty']) { $list[] = '<option value="-"> </option>'; } if ($options['reverseYears']) { for ($i = $options['endYear']; $i >= $options['startYear']; $i--) { $list[] = '<option ' . ' value="' . $i . '"' . ($valueYear === $i ? ' selected="selected"' : '') . '>' . $i . '</option>'; } } else { for ($i = $options['startYear']; $i >= $options['endYear']; $i++) { $list[] = '<option ' . ' value="' . $i . '"' . ($valueYear === $i ? ' selected="selected"' : '') . '>' . $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' => $value . '_day', 'month' => $value . '_month', 'year' => $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' => $arrayName . '[' . $name . '_day' . ']', 'month' => $arrayName . '[' . $name . '_month' . ']', 'year' => $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