Localized Form Validation

Webform validation is often integral to UX. So is i18n. Let's see how we can make both work together.

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.

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.

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:

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.

βœ‹πŸ½ 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.

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.

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.

πŸ—’ Note Β» We’ll cover the $errors object when we get to validation.

Similar to Input, we have a Checkbox class.

And a little Button class for good measure.

Easy peasy.

Putting the App Together

Let’s bring in all these modules and build an index.php page that displays our form.

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:

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.

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.

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.

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.

We use the class with calls like the following.

Validator::make() is a static factory function that creates a Validator instance. The instance tracks its given $rules, $fields, and an internal $errors object.

isAllValid() is the method that performs the actual validation. Let’s take another look at it before we break it down.

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.

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().

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.

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 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.


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 features, and sign up for a free 14-day trial.

5 (100%) 10 votes