Software localization

Localized Form Validation

Form validation is difficult to separate from UX. So is internationalization. This tutorial will show you how to make both work together smoothly.
Software localization blog category featured image | Phrase

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.

Finished demo app | Phrase

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's gettext() 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.

Unlocalized form | Phrase

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.

Arabic and English forms | Phrase

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.

Translated Thank you | Phrase

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.

Finished demo form | Phrase

The glory of errors in multiple languages

🗒 Note » You might want to take a look back at the index.php and FormControl 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.