Software localization
Localized Form Validation
We sometimes forget that form validation is really a UX endeavor. After all, we could just sanitize and/or refuse form input on the server, without letting the user know what happened. In fact, good validation error messages can empower a user and get her or him to where they want to go more quickly and with less friction. Providing this experience can be a tad tricky but certainly not too difficult. When we add the need to localize our forms (and our validation errors) to the mix, we have two systems that need to interact together to provide the absolute best UX we can. In this article, we'll do just that, building form validation i18n with an I18n
class and a Validation
class that work together.
Our Demo App
We'll create a little app to demonstrate a real-world case: a sign-up form with the usual fields. Here's what our app will look like when we're done.
Our little app: form fields with validation errors in two languages
Basic i18n
Let's get to coding. We'll build our app with PHP. You can use any language and stack you like: the basic concepts and logic should largely be the same. And we won't have any library dependencies in our app. A little home-grown i18n library should start us off on the right foot.
🔗 Resource » You can find all of the app's code on Github.
<?php class I18n { public const DEFAULT_LANG = 'en'; private static $allMessages; private static $supportedLangs; /** * @return string code for current locale */ public static function lang() { return $_REQUEST['lang'] ?? static::DEFAULT_LANG; } /** * @return 'rtl'|'ltr' layout direction of current locale */ public static function dir() { return static::lang() == 'ar' ? 'rtl' : 'ltr'; } /** * @return array all supported locales in the format `['en' => 'English', ...]` */ public static function supportedLangs() { if (static::$supportedLangs == null) { require_once 'languages.php'; static::$supportedLangs = $languages; } return static::$supportedLangs; } /** * Retrieve message translated for current locale * * @param string $key of message in translation messages file * @param array $replacements key-value pairs for interpolation * @return string */ public static function __($key, $replacements = []) { $message = static::messages()[$key] ?? $key; if (count($replacements) > 0) { foreach ($replacements as $key => $value) { $message = str_replace('{' . $key . '}', $value, $message); } } return $message; } /** * @return bool whether a messages exists in the current locale with the * given key */ public static function hasKey($key) { return isset(static::messages()[$key]); } /** * @return array all messages for all locales */ private static function allMessages() { if (static::$allMessages == null) { require_once 'messages.php'; static::$allMessages = $messages; } return static::$allMessages; } /** * @return array messages for current locale */ private static function messages() { return static::allMessages()[static::lang()]; } }
Let's go over the I18n
class one method at a time. The class is effectively a namespace for a collection of static methods.
🗒 Note » You may be using a pre-made i18n library in your project. Most i18n libraries should give you an interface close to the one we've written here; so feel free to use the i18n library that you like.
I18n::lang()
gets the current locale, which is determined from a param called lang
in the incoming request's GET
or POST
params (found in the $_REQUEST
array). A request like /index.php?lang=ar
will set our current locale to Arabic, for example. If no lang
param is found in the request, I18n::lang()
will fall back to a configured default locale.
I18n::dir()
returns the layout direction of the current locale: either "ltr"
(left-to-right) or "rtl"
(right-to-left). Our demo app will only support English and Arabic, so we're returning "rtl"
if the current locale is Arabic, and "ltr"
otherwise.
Speaking of supported locales, I18n::supportedLangs()
returns an array of those. It fetches (and caches) them from a simple languages.php
file.
<?php $languages = [ 'en' => 'English', 'ar' => 'Arabic (العربية)', ];
Locale code keys map to human-readable names of their respective locales. We'll use these names in our language switcher UI a bit later.
Back to our I18n
class: its I18n::__()
method will the one we use the most. I18n::__()
retrieves a translated message from the current locale's translations. These are maintained in a messages.php
file, which looks a little like this:
<?php $messages = [ 'en' => [ 'title' => 'Sign Up', 'subtitle' => 'Join our awesome community 😀', 'hello_user' => 'Hello {user}', // ... ], 'ar' => [ 'title' => 'إشترك', 'subtitle' => 'إنضم الى مجموعتنا الرائعة 😀', 'hello_user' => 'أهلاً {user}', // ... ], ];
Given the file above, if we call I18n::__('hello_user', ['user' => 'Adam'])
, then we'd get "Hello, Adam"
if the current locale is English.
I18n::hasKey()
is a convenience method for checking whether a key exists in the current locale's messages. Given the messages file above, I18n::hasKey('hello_user') == true
and I18n::hasKey('i_dont_exist') == false
.
Some Globals
We can wrap our most-used I18n
methods in global functions so that we don't have to reference our class every time we call them.
<?php require_once dirname(__FILE__) . '/i18n/I18n.php'; function __($key, $replacements = []) { return I18n::__($key, $replacements); } function lang() { return I18n::lang(); }
✋🏽 Heads Up » There is a built-in PHP function called
_()
(single underscore), which is an alias of PHP'sgettext()
i18n function. We've called our global function__()
(double underscore) to avoid the name collision here.
Now we can just call lang()
to get the current locale and __('title')
to get the translated app title.
Form Building
We'll use a few simple classes to make our lives easier when we build our form. Let's go over them in the quickness.
<?php require_once dirname(__FILE__) . '/../functions.php'; abstract class FormControl { public static function make($name, $errors = null, $type = null) { return new static($name, $errors, $type); } protected $name; protected $type; protected $errors; public function __construct($name, $errors, $type) { $this->name = $name; $this->type = $type; $this->errors = $errors; } abstract function render(); protected function inputClasses($defaultClass = 'input') { $classes = [$defaultClass]; if (isset($this->errors[$this->name])) { $classes[] = 'is-danger'; } return implode(' ', $classes); } protected function errorClasses($defaultClass = 'help is-danger') { $classes = [$defaultClass, "error-for-{$this->name}"]; if (!isset($this->errors[$this->name])) { $classes[] = 'is-hidden'; } return implode(' ', $classes); } }
The FormControl
abstract class provides a template for a form field like a text input or checkbox. The class provides a factory make()
method and encapsulates the name
(which identifies the control) as well as the type
(e.g. text or checkbox) of the control. FormControl
also tracks an errors
object so that it can provide two helper methods used for displaying control CSS, inputClasses()
and errorClasses()
.
The abstract render()
method in FormControl
is implemented by its children. Let's take a look at the first child of FormControl
, Input
.
<?php require_once 'FormControl.php'; require_once dirname(__FILE__) . '/../functions.php'; class Input extends FormControl { public function __construct($name, $errors, $type = 'text') { parent::__construct($name, $errors, $type); } public function render() { $name = $this->name; $label = __($this->name); $type = $this->type; $value = $_REQUEST[$this->name] ?? null; $inputClasses = $this->inputClasses(); $errorClasses = $this->errorClasses(); $errorText = $this->errors[$this->name] ?? null; return <<<HTML <div class="field"> <label class="label" for="{$name}">{$label}</label> <div class="control"> <input id="{$name}" name="{$name}" type="{$type}" value="{$value}" class="{$inputClasses}" > </div> <p class="{$errorClasses}">{$errorText}</p> </div> HTML; } }
Our Input
class should be largely self-explanatory at this point. If we were to call echo Input::make('email', $errors, 'email')->render()
, we might get output that looks like the following.
<div class="field"> <label class="label" for="email">Email</label> <div class="control"> <input id="email" name="email" type="email" value="adam@gmail." class="input is-danger" > </div> <p class="help is-danger error-for-email">Please enter a valid email address</p> </div>
🗒 Note » We'll cover the
$errors
object when we get to validation.
Similar to Input
, we have a Checkbox
class.
<?php require_once 'FormControl.php'; require_once dirname(__FILE__) . '/../functions.php'; class Checkbox extends FormControl { public function __construct($name, $errors) { parent::__construct($name, $errors, 'checkbox'); } public function render() { $name = $this->name; $label = __($this->name); $inputClasses = $this->inputClasses('checkbox'); $checkedAttr = isset($_REQUEST[$this->name]) ? 'checked' : ''; $errorClasses = $this->errorClasses(); $errorText = $this->errors[$this->name] ?? null; return <<<HTML <div class="field"> <div class="control"> <label class="{$inputClasses}"> <input type="checkbox" name="{$name}" {$checkedAttr}> {$label} </label> <p class="{$errorClasses}">{$errorText}</p> </div> </div> HTML; } }
And a little Button
class for good measure.
<?php require_once 'FormControl.php'; require_once dirname(__FILE__) . '/../functions.php'; class Button extends FormControl { public function __construct($name) { parent::__construct($name, null, null); } public function render() { $label = __($this->name); return <<<HTML <div class="field"> <div class="control"> <button class="button is-link"> {$label} </button> </div> </div> HTML; } }
Easy peasy.
Putting the App Together
Let's bring in all these modules and build an index.php
page that displays our form.
<?php require_once 'functions.php'; require_once 'form/Input.php'; require_once 'form/Button.php'; require_once 'form/Checkbox.php'; require_once 'partials/html-header.php'; require_once 'partials/lang-switcher.php'; $errors = $errors ?? []; html_header('form_validation'); ?> <body> <section class="section"> <div class="container"> <?php lang_switcher() ?> <hr> <h1 class="title"><?php echo __('title'); ?></h1> <p class="subtitle"><?php echo __('subtitle'); ?></p> <form novalidate method="POST" id="signupForm" action="/process-signup.php" > <input type="hidden" name="lang" value="<?php echo lang() ?>"> <?php echo Input::make('name', $errors)->render() ?> <?php echo Input::make('email', $errors, 'email')->render() ?> <?php echo Input::make('password', $errors, 'password')->render() ?> <?php echo Checkbox::make('agree_to_terms', $errors)->render() ?> <?php echo Button::make('sign_up')->render() ?> </form> </div> </section> </body> </html>
We'll get to the html_header()
and lang_switcher()
in just a moment. process-signup.php
will process our form, and again, we'll get to it shortly. Otherwise, index.php
is just a basic HTML page that uses our i18n library and form control classes to build a page that looks like this.
Our form is coming together
We're using the Bulma CSS framework for styling. We're also turning off HTML 5 validation by providing the novalidate
attribute in our <form>
tag. This is so we can focus on server-side validation.
A Reusable Header
We've pulled in an html-header.php
file above. Here's what that beauty looks like:
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; function html_header($titleKey) { ?> <!DOCTYPE html> <html lang="<?php echo I18n::lang(); ?>" dir="<?php echo I18n::dir() ?>"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title><?php echo I18n::__($titleKey); ?></title> <?php if (I18n::dir() == 'rtl') : ?> <link rel="stylesheet" href="/styles/bulma-rtl.min.css"> <style> .field.has-addons { flex-direction: row; } </style> <?php else : ?> <link rel="stylesheet" href="/styles/bulma.min.css"> <?php endif; ?> </head> <?php } ?>
A typical HTML header, the html_header()
function uses our I18n
class to make sure the <html>
document is localized correctly, as well as translating the page's <title>
. The function also pulls in CSS that matches the current locale's layout direction.
A Simple Locale Switcher UI
In our index.php
file above, we used a lang-switcher.php
partial to display a simple language switcher. Let's take a look at how its coded.
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; function lang_switcher() { ?> <form action="/" method="GET"> <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label" for="lang"> <?php echo I18n::__('language') ?> </label> </div> <div class="field-body"> <div class="field has-addons"> <div class="control is-expanded"> <div class="select is-fullwidth"> <select id="lang" name="lang" autocomplete="off"> <?php foreach (I18n::supportedLangs() as $key => $name) : ?> <option value="<?php echo $key ?>" <?php echo $key == I18n::lang() ? 'selected' : '' ?> > <?php echo $name ?> </option> <?php endforeach ?> </select> </div> </div> <div class="control"> <button class="button"> <?php echo I18n::__('go') ?> </button> </div> </div> </div> </div> </form> <?php } ?>
The function, lang_switcher()
, outputs a form that sets the lang
query param in the URL. You'll remember that our I18n
class uses this param to determine the active locale for our app. The user is presented with a simple <select>
dropdown and "Go" submit button to set the active locale.
Given that we have the proper translation messages in our messages.php
file, our users can now see our app in multiple languages.
Any language you choose, as long as we support it of course
Processing the Form and the Thank You Page
Before we get to validation, let's quickly process the form and display a thank you page when it's submitted.
<?php require_once 'functions.php'; header('Location: /thank-you.php?lang=' . lang()); die();
<?php require_once 'functions.php'; require_once 'partials/html-header.php'; html_header('thank_you'); ?> <body> <section class="section"> <div class="container"> <h1 class="title"><?php echo __('thank_you') ?></h1> <p class="subtitle"><?php echo __('welcome') ?></p> </div> </section> </body> </html>
We simply redirect to the thank you page on submission.
All input is allowed (no validation) 🤭
We've rounded out the form journey with a nice thank you page. But we don't have any validation in place. Let's fix that.
🔗 Resource If you want to start coding along with us from this point, without building the demo app (sans validation), be sure to check out the start branch of our Git repo on GitHub.
Localized Validation
Ok, we've set up our demo app and we're ready to validate our sign-up form. We'll build a reusable Validation
class to keep our code organized.
Defining Validation Rules
Our Validation
class will take fields and validation rules as input. We'll, of course, be getting the fields from our POST
request params. Let's take a look at how we define the rules.
<?php $rules = [ 'name' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'min|6'], 'agree_to_terms' => ['required'], ];
A simple array, $rules
, contains the rules for our signup form. Keyed by field name, each value is itself an array of rules to apply to its respective field. The password
field, for example, is required and must have a minimum length of 6 characters.
The Validator Class
Now let's jump into our Validator
. We'll take a look at the entire class, then break it down and explain its most relevant methods.
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; class Validator { public static function make($rules, $fields) { return new static($rules, $fields); } private $rules; private $fields; private $errors = []; public function __construct($rules, $fields) { $this->rules = $rules; $this->fields = $fields; } public function isAllValid() { $this->errors = []; // We spin on each field's defined rules, calling each one reflectively. // We stop at the first rule for a field that _doesn't_ pass, if one // exists. And, if we have a failing rule for a field, we add it to our // errors array. foreach ($this->rules as $field => $fieldRules) { $value = $this->fields[$field] ?? null; foreach ($fieldRules as $rule) { // Some rules, like min(imum) length, have arguments. So // we parse each rule (min|6) to separate the name (min) // from the argument (6). [$ruleName, $ruleArg] = $this->parseRule($rule); $validateMethod = "validate_{$ruleName}"; if (!$this->$validateMethod($value, $ruleArg)) { $this->errors[$field] = $this->getErrorText($field, $ruleName, $ruleArg); break; } } } return count($this->errors) == 0; } public function errors() { return $this->errors; } private function parseRule($rule) { $parts = explode('|', trim($rule)); $ruleName = $parts[0]; $arg = count($parts) == 1 ? null : $parts[1]; return [$ruleName, $arg]; } private function getErrorText($field, $rule, $arg) { $key = I18n::hasKey("error_{$rule}_{$field}") ? "error_{$rule}_{$field}" : // error_required_first_name "error_{$rule}"; // error_required $translatedField = ucwords(I18n::__($field)); return I18n::__($key, ['field' => $translatedField, 'arg' => $arg]); } private function validate_required($value) { return $value != null && strlen(trim($value)) > 0; } private function validate_email($value) { return !!filter_var(trim($value), FILTER_VALIDATE_EMAIL); } private function validate_min($value, $min) { return strlen(trim($value)) >= $min; } }
We use the class with calls like the following.
<?php $validator = Validator::make($rules, $_POST); if ($validator->isAllValid()) { // all is well, proceed } else { // oops $errors = $validator->errors(); }
Validator::make()
is a static factory function that creates a Validator
instance. The instance tracks its given $rules
, $fields
, and an internal $errors
object.
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; class Validator { public static function make($rules, $fields) { return new static($rules, $fields); } private $rules; private $fields; private $errors = []; public function __construct($rules, $fields) { $this->rules = $rules; $this->fields = $fields; } // ... }
isAllValid()
is the method that performs the actual validation. Let's take another look at it before we break it down.
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; class Validator { // ... public function isAllValid() { $this->errors = []; // We spin on each field's defined rules, calling each one reflectively. // We stop at the first rule for a field that _doesn't_ pass, if one // exists. And, if we have a failing rule for a field, we add it to our // errors array. foreach ($this->rules as $field => $fieldRules) { $value = $this->fields[$field] ?? null; foreach ($fieldRules as $rule) { // Some rules, like min(imum) length, have arguments. So // we parse each rule (min|6) to separate the name (min) // from the argument (6). [$ruleName, $ruleArg] = $this->parseRule($rule); $validateMethod = "validate_{$ruleName}"; if (!$this->$validateMethod($value, $ruleArg)) { $this->errors[$field] = $this->getErrorText($field, $ruleName, $ruleArg); break; } } } return count($this->errors) == 0; } // ... }
In isAllValid()
, we first clear our internal $errors
array. We then iterate over all of our given $rules
(['name' => ['required'], 'email' => ['required', 'email'], ...
). We grab the value of the current field in our loop, since we'll need to validate this value against the rule.
We then iterate over the current field's rules (['required', 'email']
). In order to account for rules that have an argument, like our minimum length rule ('min|6'
), we parse the rule into its component parts. We use the name part ('min'
) to call an associated validation method, e.g. validate_min()
. This is easy using PHP's reflection, which allows us call a method using a string variable.
We call the validation method for the current rule to the current field's value passes the rule. If it doesn't, we log the error in our $errors
object, and we stop iterating over the current field's rules. This is to ensure that we're always reporting only the first error we encounter for a field.
When we're done with our loops, $errors
will be empty if all our fields are valid. Otherwise, $errors
will look something like ['name' => 'Name is required', 'password' => 'Password must be 6 or more characters
].
Localized Error Text
The error text in our $errors
object is retrieved by calling the Validator
's getErrorText()
method. We'll take a look at this method in detail in a moment. First, let's see how our translated error messages are formatted in our translations messages.php
file.
<?php $messages = [ 'en' => [ // ... 'error_required' => '{field} is required', 'error_required_name' => 'Please enter your name', 'error_required_agree_to_terms' => 'Please agree to terms and conditions', 'error_email' => 'Please enter a valid email address', 'error_min' => '{field} must be {arg} characters or more' ], 'ar' => [ // ... 'error_required' => 'حقل {field} مطلوب', 'error_required_name' => 'الرجاء إدخال إسمك', 'error_email' => 'الرجاء إدخال بريد إلكتروني صالح', 'error_min' => 'الحقل {field} يجب و أن يكون طوله {arg} حروف على الأقل', 'error_required_agree_to_terms' => 'الرجاء الموافقة على الشروط و الأحكام', ], ];
There are two kinds of error messages: generic rule messages and field-specific rule messages. The generic required rule message, error_required
, for example, covers any field that doesn't have a field-specific required message. The name field does have a specific message, error_required_name
, which will supersede the generic error_required
. Let's see how that all works in getErrorText()
.
<?php require_once dirname(__FILE__) . '/../i18n/I18n.php'; class Validator { // ... private function getErrorText($field, $rule, $arg) { $key = I18n::hasKey("error_{$rule}_{$field}") ? "error_{$rule}_{$field}" : // error_required_first_name "error_{$rule}"; // error_required $translatedField = ucwords(I18n::__($field)); return I18n::__($key, ['field' => $translatedField, 'arg' => $arg]); } // ... }
getErrorText()
receives the $field
('name'
), the $rule
('required'
), and an $arg
for the rule (e.g. a numerical string, like '6'
, for the minimum length rule). Not all rules have arguments, of course, and in those cases $arg
will be null
.
We use these values to check whether a matching field-specific message exists (error_required_name
). If it does, we use it. Otherwise, we default to the generic rule message (error_required
).
In case the message provides a {field}
parameter for interpolation ("{field} is required"
), we provide it in the I18n::__()
method's second parameter. Before we do, however, we translate the name of the field. This ensures that we're not injecting the English name of the field in non-English messages.
We also title-case the name of the field using PHP's ucwords()
function. This just makes our field names look a bit nicer in sentences.
Of course, we pass the {arg}
value to I18n::__()
as well, since some messages will need to show it.
Updating our Processing Script
With our Validator
in place, we can use it to validate our sign up form.
<?php require_once 'functions.php'; require_once 'validation/rules.php'; require_once 'validation/Validator.php'; $validator = Validator::make($rules, $_POST); if ($validator->isAllValid()) { header('Location: /thank-you.php?lang=' . lang()); die(); } else { $errors = $validator->errors(); require_once 'index.php'; }
We create a Validator
using our signup $rules
(in rules.php
) and the fields that were given as $_POST
params in our request. The $_POST
params will be our form fields and their values, of course. We then test for form validation. If all fields are valid, we redirect to our thank you page. Otherwise, we set our validation errors in the $errors
global variable and show our form page again.
Here's what it looks like when all is said and done.
The glory of errors in multiple languages
🗒 Note » You might want to take a look back at the
index.php
andFormControl
classes to see how we're conditionally displaying error colours and messages on the form page.
🔗 Resource » Grab the completed code from our Github repo.
Adios
Alright, that's it for now. We hope you enjoyed our little dive into form validation i18n, and that you learned a thing or two along the way. If you're wanting to take your form and app i18n to the next level, take a look at Phrase. Packed with developer features like an API, CLI, branching and versioning, over-the-air (OTA) translations, Bitbucket and Github integration, and a whole lot more, Phrase is a localization platform that takes care of i18n tedium so you can focus on your business logic. Check out all of Phrase's products, and sign up for a free 14-day trial.