How to internationalize a project
Overview
Symfony has native internationalization automatisms that make the development of multilingual and locally adapted web applications a painless task.
Introduction
The internationalization (i18n) of an application covers three aspects:
- standards and formats (dates, amounts, numbers, etc.).
- text information contained in the database
- text translation (interface and content)
Symfony brings a solution to each of these issues.
User culture
The sfUser class, used to manage the user session, has a native implementation of the user language and country, which is called culture. Symfony provides a getter and a setter method for this attribute. Here is an example of their use in an action:
// getter
$culture = $this->getUser()->getCulture();
// setter
$this->getUser()->setCulture('en_US');
This culture is persistent between pages because it is serialized in the user session.
Keeping both the language and the country in the culture is necessary because you may have a different French translation for users from France, Belgium or Canada, and a different Spanish translation for users from Spain or Mexico.
The language is coded in two lower-case characters, according to the ISO 639-1 norm (for instance en for English).
The country is coded in two upper-case characters, according to the ISO 3166-1 norm (for instance GB for Great-Britain).
By default, any new user will take the culture set in the default_culture configuration parameter. You can change it in the i18n.yml configuration file:
all:
default_culture: fr
All the culture-dependent contents are displayed transparently according to the user culture.
Standards and formats
Once the culture is defined, the helpers depending on it will automatically have a proper output. Here is a list of helpers that take into account the user culture for their output:
// formatting helpers
format_date($date, $format)
format_datetime($date, $format)
format_number($number)
format_currency($amount, $currency)
format_country($country_iso)
// form helpers
input_date_tag($name, $value, $options)
select_country_tag($name, $value, $options)
For instance,
<?php echo format_number(12000.10) ?>
// will generate in HTML with a culture set to en_US
12,000.10
// will generate in HTML with a culture set fr_FR
12 000,10
If you want to know more about the helpers that depend on culture, refer to the form helpers, i18n helpers and other helpers documentation.
Text information in the database
For each table that contains some i18n data, it is recommended to split the table in two parts: one table with no i18n column, and the other one with only the i18n columns. This setup lets you add more languages when needed without a change to your model. Let's take an example with a Product table.
First, create tables in the schema.yml file:
my_connection:
my_product:
_attributes: { phpName: Product, isI18N: true, i18nTable: my_product_i18n }
id: { type: integer, required: true, primaryKey: true, autoincrement: true }
price: { type: float }
my_product_i18n:
_attributes: { phpName: ProductI18n }
id: { type: integer, required: true, primaryKey: true, foreignTable: my_product, foreignReference: id }
culture: { isCulture: true, type: varchar, size: 7, required: true, primaryKey: true }
name: { type: varchar, size: 50 }
Notice the isI18N and i18nTable attributes of the first table key, and the special culture column. Also, the _i18n suffix of the second table is a convention that automates many data access mechanisms. All these are symfony specific Propel enhancements.
Note: The symfony automatisms can make this much faster to write. If the table containing internationalized data has the same name as the main table with _i18n as a suffix, and that they are related with a column named id in both tables, you can write the same as above with only:
my_connection:
my_product:
_attributes: { phpName: Product}
id:
price: float
my_product_i18n:
_attributes: { phpName: ProductI18n }
name: varchar(50)
You can find more about the schema syntax and automatisms in the model chapter.
Once the corresponding object model is built (don't forget to call symfony propel-build-model and clear the cache with a symfony cc after each modification of the schema.yml), you can use your Product class with i18n support as if there was only one table:
$product = ProductPeer::retrieveByPk(1);
$product->setCulture('fr');
$product->setName('Nom du produit');
$product->save();
$product->setCulture('en');
$product->setName('Product name');
$product->save();
echo $product->getName(); => 'Product name'
$product->setCulture('fr');
echo $product->getName(); => 'Nom du produit'
Note: If you don't want to remember to change the culture each time you use an i18n object, you can also change the hydrate method in the object class. In the previous example, add the following function to the myproject/lib/model/Product.php:
public function hydrate(ResultSet $rs, $startcol = 1)
{
parent::hydrate($rs, $startcol);
$this->setCulture(SF_DEFAULT_CULTURE);
}
or even, to get the actual user culture:
public function hydrate(ResultSet $rs, $startcol = 1)
{
parent::hydrate($rs, $startcol);
$this->setCulture(sfContext::getInstance()->getUser()->getCulture());
}
Interface translation
Symfony stores the user interface translations in XML configuration files in the standard XLIFF format. Here is an extract of a XLIFF file messages.fr.xml, where a website originally written in English is translated in French:
<?xml version="1.0" ?>
<xliff version="1.0">
<file orginal="global" source-language="en_US" datatype="plaintext" date="2004-12-28T18:10:19Z">
<body>
<trans-unit id="1">
<source>original English text</source>
<target>French translation of the text</target>
</trans-unit>
</body>
</file>
</xliff>
If additional translations need to be done, simply add a new messages.XX.xml translation file. These files must be stored in the apps/myapp/i18n directory.
The use of the XLIFF format allows you to use common translation tools to reference all text in your website and translate it, without the need of a specific tool to build for translators.
Lets say you want to have a website in English and French, with English being the default language. The example page is supposed to display the phrase 'Welcome to our website. Today's date is ' followed by the actual date.
For this to work you need to:
Activate the i18N interface translation in the application settings.yml
all:
.settings:
i18n: on
Activate the I18N helper by adding ,I18N to standard_helpers: in the settings.yml file. Alternatively, you can put <?php use_helper('I18N') ?> at the top of the template where you want to use internationalisation.
The same goes for the 'Date' helper to make sure the date is being formatted right.
In the template, enclose all the texts in calls to the __() function. So, to have an internationalized template displaying:
Welcome to our website. Today's date is
<?php echo format_date(date()) ?>
It must be written like this:
<?php echo __('Welcome to our website.') ?> // (this matches the first trans-unit's source node)
<?php echo __('Today's date is ') ?> // (this matches the second trans-unit's source node)
<?php echo format_date(date()) ?>
Make a messages.fr.xml file in the app/i18n directory, according to the XLIFF format, containing one trans-unit node for eah call to the __() function:
<?xml version="1.0" ?>
<xliff version="1.0">
<file orginal="global" source-language="en_US" datatype="plaintext">
<body>
<trans-unit id="1">
<source>Welcome to our website.</source>
<target>Bienvenue sur notre site web</target>
</trans-unit>
<trans-unit id="2">
<source>Today's date is </source>
<target>La date d'aujourd'hui est </target>
</trans-unit>
</body>
</file>
</xliff>
Notice the source-language="...": put the full iso code of your default culture here.
The default user culture is set to en_US, so the text outputs in English. However, if the culture is changed to fr_BE with:
$this->getUser()->setCulture('fr_BE');
...the text then outputs in French.
If, in the near future, you want to add a Dutch translation, all you have to do is duplicate messages.fr.xml and call it messages.nl.xml. Then, open the newly created file and change all the text contained in the <target> nodes to the dutch translation.
Note that translation only makes sense if the translation files contains full sentences. However, as you sometimes have formatting or variables in the text, you could be tempted to cut the sentence into several parts. For instance, to translate:
Welcome to all the <b>new</b> users.<br />
There are <?php echo count_logged() ?> persons logged.
You could to write:
<?php echo __('Welcome to all the') ?><b><?php echo __('new') ?> </b><?php echo __('users') ?> <br />
<?php echo __('There are') ?><?php echo count_logged() ?><?php echo __('persons logged') ?>
But with this template, your translation file would be totally incomprehensible for translators. You'd better do:
<?php echo __('Welcome to all the <b>new</b> users') ?> <br />
<?php echo __('There are %1% persons logged', array('%1%' => count_logged())) ?>
The syntax of the last lines illustrates the ability to do a simple substitution within the __() function, to avoid unnecessary chunking of text.
One of the common problems with translation is the use of the plural form. According to the number of results, the text changes, such as in:
<?php echo __('There are %1% persons logged', array('%1%' => count_logged())) ?>
What if there is only one person logged? The message would be:
<?php echo __('There is 1 person logged') ?>
To avoid multiple tests, symfony provides a special translation helper called format_number_choice(). Use it like this:
<?php echo format_number_choice(
'[0]Nobody is logged|[1]There is 1 person logged|(1,+Inf] There are %1% persons logged',
array('%1%' => count_logged()),
count_logged()
) ?>
The first argument is the multiple possibilities of text. The second argument is the replacement pattern (as for the __() helper) and is optional. The third argument is the number on which the test is made to determine which text is taken.
The message/string choices are separated by the pipe "|" followed by a set notation of the form
[1,2]: accepts values between 1 and 2, inclusive.
(1,2): accepts values between 1 and 2, excluding 1 and 2.
{1,2,3,4}: only values defined in the set are accepted.
[-Inf,0): accepts value greater or equal to negative infinity and strictly less than 0
Any non-empty combinations of the delimiters of square and round brackets are acceptable.
|