This is part four of the Building Your Startup With PHP series on Tuts+. In this series, I'm guiding you through launching a startup from concept to reality using my Meeting Planner app as a real life example. Every step along the way, we'll release the Meeting Planner code as open source examples you can learn from. We'll also address startup-related business issues as they arise.
In this tutorial, I wanted to step back and add I18n internationalization support to our application before we build more and more code. According to Wikipedia, I18n is a numeronym:
18 stands for the number of letters between the first i and last n in internationalization, a usage coined at DEC in the 1970s or 80s.
With I18n, all of the text strings displayed to the user from the application are replaced by function calls which can dynamically load translated strings for any language the user selects.
All of the code for Meeting Planner is written in the Yii2 Framework for PHP, which has built-in support for I18n. If you'd like to learn more about Yii2, check out our parallel series Programming With Yii2 at Tuts+.
Just a reminder, I do participate in the comment threads below. I'm especially interested if you have different approaches, or additional ideas, or want to suggest topics for future tutorials.
The Goals of Internationalization
When building a startup, it's useful to think globally from the beginning—but not always. Alternately, it may make sense to focus only on building for your local market. Does your minimum viable product need to work in other languages for users from different countries?
In our case, the Yii Framework provides built-in support for I18n, so it's relatively easy to build support in for I18n from the beginning—and time-consuming to add it in later.
How I18n Works
I18n operates by replacing all references to text displayed to the user with function calls that provide translation when needed.
For example, here's what the attribute field names in the Place model look like before I18n:
public function attributeLabels() { return [ 'id' => 'ID', 'name' => 'Name', 'place_type' => 'Place Type', ...
Providing translated versions of the code would become very complicated. Non-technical translators would have to translate code in place, likely breaking syntax.
Here's what the same code looks like with I18n:
public function attributeLabels() { return [ 'id' => Yii::t('frontend', 'ID'), 'name' => Yii::t('frontend', 'Name'), 'place_type' => Yii::t('frontend', 'Place Type'),
Yii:t()
is a function calls that checks what language is currently selected and displays the appropriate translated string. 'frontend'
refers to a section of our application. Translations can be optionally organized according to various categories. But, where do these translated strings appear?
The default language, in this case English, is written into the code, as shown above. Language resource files are lists of arrays of strings whose key is the default language text—e.g. 'Place Type'—and each file provides translated text values for their appropriate language.
Here's an example of our completed Spanish translation file, language code "es". The Yii:t()
function uses this file to find the appropriate translation to display:
<?php /** * Message translations. * * This file is automatically generated by 'yii message' command. * It contains the localizable messages extracted from source code. * You may modify this file by translating the extracted messages. * * Each array element represents the translation (value) of a message (key). * If the value is empty, the message is considered as not translated. * Messages that no longer need translation will have their translations * enclosed between a pair of '@@' marks. * * Message string can be used with plural forms format. Check i18n section * of the guide for details. * * NOTE: this file must be saved in UTF-8 encoding. */ return [ 'Add Current Location' => 'Agregar ubicación actual', 'Add a Google {modelClass}' => 'Añadir un Google {modelClass}', 'Created By' => 'Creado por', 'Full Address' => 'Dirección completa', 'Google Place ID' => 'Google Place ID', 'Name' => 'Nombre', 'Notes' => 'Notas', 'Place Type' => 'Place Tipo', 'Places' => 'Lugares',
While this looks time-consuming, Yii provides scripts to automate the generation and organization of these files.
By separating the text from the code, we make it easier for non-technical multi-lingual experts to translate our applications for us—without breaking the code.
I18n also offers specialized functions for translating time, currency, plurals et al. I won't go into the detail of these in this tutorial.
Configuring I18n Support
Unfortunately, the Yii2 documentation for I18n is not yet very descriptive—and it was difficult to find working, step by step examples. Luckily for you, I'm going to walk you through what I've learned from scouring the docs and the web. I found The Code Ninja's I18n example and the Yii2 Definitive Guide on I18n helpful, and Yii contributor Alexander Makarov offered me some assistance as well.
Generating the I18n Configuration File
We're using the Yii2 advanced template for Meeting Planner. This creates two Yii applications in our codebase, frontend and backend. And, it creates a common area for models shared between both applications. Yii's configuration files is loaded whenever page requests are made. We'll use Yii's I18n message scripts to build out a configuration file for I18n in the common/config
path.
From our codebase root, we'll run the Yii message/config
script:
./yii message/config @common/config/i18n.php
This generates the following file template which we can customize:
<?php return [ // string, required, root directory of all source files 'sourcePath' => __DIR__, // array, required, list of language codes that the extracted messages // should be translated to. For example, ['zh-CN', 'de']. 'languages' => ['de'], // string, the name of the function for translating messages. // Defaults to 'Yii::t'. This is used as a mark to find the messages to be // translated. You may use a string for single function name or an array for // multiple function names. 'translator' => 'Yii::t', // boolean, whether to sort messages by keys when merging new messages // with the existing ones. Defaults to false, which means the new (untranslated) // messages will be separated from the old (translated) ones. 'sort' => false, // boolean, whether to remove messages that no longer appear in the source code. // Defaults to false, which means each of these messages will be enclosed with a pair of '@@' marks. 'removeUnused' => false, // array, list of patterns that specify which files/directories should NOT be processed. // If empty or not set, all files/directories will be processed. // A path matches a pattern if it contains the pattern string at its end. For example, // '/a/b' will match all files and directories ending with '/a/b'; // the '*.svn' will match all files and directories whose name ends with '.svn'. // and the '.svn' will match all files and directories named exactly '.svn'. // Note, the '/' characters in a pattern matches both '/' and '\'. // See helpers/FileHelper::findFiles() description for more details on pattern matching rules. 'only' => ['*.php'], // array, list of patterns that specify which files (not directories) should be processed. // If empty or not set, all files will be processed. // Please refer to "except" for details about the patterns. // If a file/directory matches both a pattern in "only" and "except", it will NOT be processed. 'except' => [ '.svn', '.git', '.gitignore', '.gitkeep', '.hgignore', '.hgkeep', '/messages', ], // 'php' output format is for saving messages to php files. 'format' => 'php', // Root directory containing message translations. 'messagePath' => __DIR__ . DIRECTORY_SEPARATOR . 'messages', // boolean, whether the message file should be overwritten with the merged messages 'overwrite' => true, /* // 'db' output format is for saving messages to database. 'format' => 'db', // Connection component to use. Optional. 'db' => 'db', // Custom source message table. Optional. // 'sourceMessageTable' => '{{%source_message}}', // Custom name for translation message table. Optional. // 'messageTable' => '{{%message}}', */ /* // 'po' output format is for saving messages to gettext po files. 'format' => 'po', // Root directory containing message translations. 'messagePath' => __DIR__ . DIRECTORY_SEPARATOR . 'messages', // Name of the file that will be used for translations. 'catalog' => 'messages', // boolean, whether the message file should be overwritten with the merged messages 'overwrite' => true, */ ];
I'm customizing my file. I move messagePath
up to the top and customize sourcePath
and messagePath
. I am also specifying the languages I want my application to support besides English—in this case Spanish and German, 'es' and 'de'. Here's a list of all the I18n language codes.
return [ // string, required, root directory of all source files 'sourcePath' => __DIR__. DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR, // Root directory containing message translations. 'messagePath' => __DIR__ . DIRECTORY_SEPARATOR .'..'. DIRECTORY_SEPARATOR . 'messages', // array, required, list of language codes that the extracted messages // should be translated to. For example, ['zh-CN', 'de']. 'languages' => ['es','de'],
In the next step, we'll run Yii's extract script, which will scan all the code in the sourcePath
tree to generate default string files for all the labels used in our code. I'm customizing sourcePath
to scan the entire code tree. I'm customizing messagePath
to generate the resulting files in common/messages
.
./yii message/extract @common/config/i18n.php
You'll see Yii scanning all of your code files:
Extracting messages from /Users/Jeff/Sites/mp/frontend/models/Place.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/models/PlaceGPS.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/models/PlaceSearch.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/models/ResetPasswordForm.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/models/SignupForm.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/layouts/main.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/_form.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/_search.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/create.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/index.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/update.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/meeting/view.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/_form.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/_formGeolocate.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/_formPlaceGoogle.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/_search.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/create.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/create_geo.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/create_place_google.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/index.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/locate.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/update.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/place/view.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/about.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/contact.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/error.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/index.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/login.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/requestPasswordResetToken.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/resetPassword.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/views/site/signup.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/web/index-test.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/web/index.php... Extracting messages from /Users/Jeff/Sites/mp/frontend/widgets/Alert.php...
When it completes, you'll see something like this in your codebase:
Activating I18n and Selecting A Language
In the common configuration file, common/config/main.php
, we're going to tell Yii about our new language support. I'll make Spanish my default language:
<?php return [ 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'language' => 'es', // spanish 'components' => [ 'cache' => [ 'class' => 'yii\caching\FileCache', ], 'i18n' => [ 'translations' => [ 'frontend*' => [ 'class' => 'yii\i18n\PhpMessageSource', 'basePath' => '@common/messages', ], 'backend*' => [ 'class' => 'yii\i18n\PhpMessageSource', 'basePath' => '@common/messages', ], ], ], ], ];
But there's still more to do. We have to make our code I18n aware.
Using Yii's Gii Code Generator With I18n
In part two of this series, Building Your Startup With PHP: Feature Requirements and Database Design, we used Yii's awesome code generator, Gii, to generate our models, controllers and views. But, we did not activate I18n, so all of our code embedded text strings. Let's redo this.
We return to Gii, likely http://localhost:8888/mp/gii in your browser, and re-run the model and controller generators with I18n activated.
Note: If you kept up with part three of our series, you may need to make note of the differences with each file and manually move code over. Or, it may be easiest to replace your code with the Github repository for this tutorial, linked at the upper right.
Here's an example of generating the Meeting model code with I18n activated. Notice that we specify "frontend" for our Message Category. We're placing all of our frontend text strings in one frontend category file.
Let's do the same for the CRUD generation for controllers and views:
If you browse the generated code in models, controllers and views, you'll see the text strings replaced with the Yii:t('frontend',...)
function:
<?php use yii\helpers\Html; use yii\grid\GridView; /* @var $this yii\web\View */ /* @var $searchModel frontend\models\PlaceSearch */ /* @var $dataProvider yii\data\ActiveDataProvider */ $this->title = Yii::t('frontend', 'Places'); $this->params['breadcrumbs'][] = $this->title; ?> <div class="place-index"> <h1><?= Html::encode($this->title) ?></h1> <?php // echo $this->render('_search', ['model' => $searchModel]); ?> <p> <?= Html::a(Yii::t('frontend', 'Create {modelClass}', [ 'modelClass' => 'Place', ]), ['create'], ['class' => 'btn btn-success']) ?> <?= Html::a(Yii::t('frontend','Add Current Location'), ['create_geo'], ['class' => 'btn btn-success']) ?> <?= Html::a(Yii::t('frontend','Add a Google {modelClass}',[ 'modelClass' => 'Place' ]), ['create_place_google'], ['class' => 'btn btn-success']) ?> </p>
Translating Your Message Files
Take a look at our Spanish message file, /common/messages/es/frontend.php
. It's a long list of empty array values:
return [ 'Add Current Location' => '', 'Add a Google {modelClass}' => '', 'Are you sure you want to delete this item?' => '', 'Create' => '', 'Create {modelClass}' => '', 'Created At' => '', 'Created By' => '', 'Delete' => '', 'Full Address' => '', 'Google Place ID' => '', 'ID' => '', 'Meeting Type' => '', 'Meetings' => '', 'Message' => '', 'Name' => '', 'Notes' => '', 'Owner ID' => '', 'Place Type' => '', 'Places' => '', 'Reset' => '', 'Search' => '', 'Slug' => '', 'Status' => '', 'Update' => '', 'Update {modelClass}: ' => '', 'Updated At' => '', 'Vicinity' => '', 'Website' => '', ];
For the purposes of filling in our Spanish language translations for this tutorial, I'll use Google Translate. Tricky, huh?
Then, we'll do some cut and paste with those translations back into the message file.
return [ 'Add Current Location' => 'Agregar ubicación actual', 'Add a Google {modelClass}' => 'Añadir un Google {modelClass}', 'Created By' => 'Creado por', 'Full Address' => 'Dirección completa', 'Google Place ID' => 'Google Place ID', 'Name' => 'Nombre', 'Notes' => 'Notas', 'Place Type' => 'Place Tipo', 'Places' => 'Lugares', 'Slug' => 'Slug', 'Vicinity' => 'Alrededores', 'Website' => 'Sitio Web', 'Are you sure you want to delete this item?' => '¿Estás seguro que quieres borrar este elemento?', 'Create' => 'Crear', 'Create {modelClass}' => 'Crear Lugar', 'Created At' => 'Creado El', 'Delete' => 'Eliminar', 'ID' => 'ID', 'Meeting Type' => 'Tipo de Reunión', 'Meetings' => 'Encuentros', 'Message' => 'Mensaje', 'Owner ID' => 'Propietario ID', 'Reset' => 'Reset', 'Search' => 'Buscar', 'Status' => 'Estado', 'Update' => 'Actualizar', 'Update {modelClass}: ' => 'Actualizar Lugar', 'Updated At' => 'Actualización A', ];
When we visit the Place index page, you'll see the Spanish version—nice, huh?
Notice that the navigation bar remains in English—that's because the Message/Extract script didn't pick up the Bootstrap navigation array definitions and convert them to using Yii:t()
. We'll do that by hand. Also, notice that Home and paging text was translated automatically—Yii's codebase includes language translations for these standard strings.
Here's the Create A Place form:
If I want to switch back to English, I just change the configuration file, /common/main.php
, back to English:
<?php return [ 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'language' => 'en', // english // 'language' => 'es', // spanish
You'll also notice as you proceed that replacing strings in JavaScript has its own complexities. I haven't explored it myself, but the Yii 1.x JsTrans extension may provide a useful guideline for supporting this.
Going Further With I18n
Ultimately, we may want to translate our application into a number of languages. I've posted a Yii feature request to extend the Message/Extract script to use the Google Translate API to automate this process. I've also asked Tuts+ to let me write a tutorial about it, so stay tuned. Of course, this just provides a base translation. You may want to hire professional translators to tune the files afterwards.
Some applications allow users to select their native language so that when they log in, the user interface automatically translates for them. In Yii, setting the $app->language
variable does this:
\Yii::$app->language = 'es';
Other applications, like JScrambler.com below, leverage the URL path to switch languages. The user just clicks the language prefix they want, e.g. "FR", and the app is automatically translated:
Note: Watch my Tuts+ instructor page for an upcoming tutorial about JScrambler—it's a pretty useful service. It may have already appeared by the time you read this.
Yii's URL Manager can provide this type of functionaliaty as well. I will probably implement these features for Meeting Planner in a future tutorial.
What's Next?
I hope you've learned something new with this tutorial. I had used I18n with Rails before—but this was the first time I'd implemented it with PHP. Watch for upcoming tutorials in our Building Your Startup With PHP series—there are lots of fun features coming up.
Please feel free add your questions and comments below; I generally participate in the discussions. You can also reach me on Twitter @reifman or email me directly.
Comments