Yesterday, we finished the search engine feature by making it more fun with the addition of some AJAX goodness. Now, we will talk about Jobeet internationalization (or i18n) and localization (or l10n).
From Wikipedia:
Internationalization is the process of designing a software application so that it can be adapted to various languages and regions without engineering changes.
Localization is the process of adapting software for a specific region or language by adding locale-specific components and translating text.
As always, the symfony framework has not reinvented the wheel and its i18n and l10n supports is based on the ICU standard.
User
No internationalization is possible without a user. When your website is available in several languages or for different regions of the world, the user is responsible for choosing the one that fits him best.
note
We have already talked about the symfony User class during day 13.
The User Culture
The i18n and l10n features of symfony are based on the user
culture. The culture is the combination of the language and the
country of the user. For instance, the culture for a user that speaks French is
fr
and the culture for a user from France is fr_FR
.
You can manage the user culture by calling the setCulture()
and getCulture()
methods on the User
object:
// in an action $this->getUser()->setCulture('fr_BE'); echo $this->getUser()->getCulture();
tip
The language is coded in two lowercase characters, according to the ISO 639-1 standard, and the country is coded in two uppercase characters, according to the ISO 3166-1 standard.
The Preferred Culture
By default, the user culture is the one configured in the settings.yml
configuration file:
# apps/frontend/config/settings.yml all: .settings: default_culture: it_IT
tip
As the culture is managed by the User object, it is stored in the user session. During development, if you change the default culture, you will have to clear your session cookie for the new setting to have any effect in your browser.
When a user starts a session on the Jobeet website, we can also determine the
best culture, based on the information provided by the Accept-Language
HTTP
header|HTTP Headers.
The getLanguages()
method of the request object returns an array of accepted
languages for the current user, sorted by order of preference:
// in an action $languages = $request->getLanguages();
But most of the time, your website won't be available in the world's 136 major
languages. The getPreferredCulture()
method returns the best language by
comparing the user preferred languages and the supported languages of your
website:
// in an action $language = $request->getPreferredCulture(array('en', 'fr'));
In the previous call, the returned language will be English or French according to the user preferred languages, or English (the first language in the array) if none match.
Culture in the URL
The Jobeet website will be available in English and French. As an URL can only
represent a single resource, the culture must be embedded in the URL. In order
to do that, open the routing.yml
file, and add the special :sf_culture
variable for all routes but the api_jobs
and the homepage
ones. For simple
routes, add /:sf_culture
to the front of the url
. For collection routes, add
a prefix_path
option that starts with /:sf_culture
.
# apps/frontend/config/routing.yml affiliate: class: sfDoctrineRouteCollection options: model: JobeetAffiliate actions: [new, create] object_actions: { wait: get } prefix_path: /:sf_culture/affiliate category: url: /:sf_culture/category/:slug.:sf_format class: sfDoctrineRoute param: { module: category, action: show, sf_format: html } options: { model: JobeetCategory, type: object } requirements: sf_format: (?:html|atom) job_search: url: /:sf_culture/search param: { module: job, action: search } job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: put, extend: put } prefix_path: /:sf_culture/job requirements: token: \w+ job_show_user: url: /:sf_culture/job/:company_slug/:location_slug/:id/:position_slug class: sfDoctrineRoute options: model: JobeetJob type: object method_for_query: retrieveActiveJob param: { module: job, action: show } requirements: id: \d+ sf_method: get
When the sf_culture
variable is used in a route, symfony will automatically
use its value to change the culture of the user.
As we need as many homepages as languages we support (/en/
, /fr/
, ...), the
default homepage (/
) must redirect to the appropriate localized one, according
to the user culture. But if the user has no culture yet, because he comes to
Jobeet for the first time, the preferred culture will be chosen for him.
First, add the isFirstRequest()
method to myUser
. It returns true
only for
the very first request of a user session:
// apps/frontend/lib/myUser.class.php public function isFirstRequest($boolean = null) { if (is_null($boolean)) { return $this->getAttribute('first_request', true); } $this->setAttribute('first_request', $boolean); }
Add a localized_homepage
route:
# apps/frontend/config/routing.yml localized_homepage: url: /:sf_culture/ param: { module: job, action: index } requirements: sf_culture: (?:fr|en)
Change the index
action of the job
module to implement the logic to redirect
the user to the "best" homepage on the first request of a session:
// apps/frontend/modules/job/actions/actions.class.php public function executeIndex(sfWebRequest $request) { if (!$request->getParameter('sf_culture')) { if ($this->getUser()->isFirstRequest()) { $culture = $request->getPreferredCulture(array('en', 'fr')); $this->getUser()->setCulture($culture); $this->getUser()->isFirstRequest(false); } else { $culture = $this->getUser()->getCulture(); } $this->redirect('localized_homepage'); } $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs(); }
If the sf_culture
variable is not present in the request, it means that the
user has come to the /
URL. If this is the case and the session is new, the
preferred culture is used as the user culture. Otherwise the user's current
culture is used.
The last step is to redirect the user to the localized_homepage
URL. Notice
that the sf_culture
variable has not been passed in the redirect call as
symfony adds it automatically for you.
Now, if you try to go to the /it/
URL, symfony will return a 404
error as we have restricted the sf_culture
variable to en
, or fr
. Add this
requirement to all the routes that embed the culture:
requirements: sf_culture: (?:fr|en)
Culture Testing
It is time to test our implementation. But before adding more tests, we need to
fix the existing ones. As all URLs have changed, edit all functional test files
in test/functional/frontend/
and add /en
in front of all URLs. Don't forget
to also change the URLs in the lib/test/JobeetTestFunctional.class.php
file.
Launch the test suite to check that you have correctly fixed the tests:
$ php symfony test:functional frontend
The user tester provides an isCulture()
method that tests the current user's
culture. Open the jobActionsTest
file and add the following tests:
// test/functional/frontend/jobActionsTest.php $browser->setHttpHeader('ACCEPT_LANGUAGE', 'fr_FR,fr,en;q=0.7'); $browser-> info('6 - User culture')-> restart()-> info(' 6.1 - For the first request, symfony guesses the best culture')-> get('/')-> with('response')->isRedirected()-> followRedirect()-> with('user')->isCulture('fr')-> info(' 6.2 - Available cultures are en and fr')-> get('/it/')-> with('response')->isStatusCode(404) ; $browser->setHttpHeader('ACCEPT_LANGUAGE', 'en,fr;q=0.7'); $browser-> info(' 6.3 - The culture guessing is only for the first request')-> get('/')-> with('response')->isRedirected()-> followRedirect()-> with('user')->isCulture('fr') ;
Language Switching
For the user to change the culture, a language form must be added in the
layout. The form framework does not provide such a form out of the box but as
the need is quite common for internationalized websites, the symfony core team
maintains the
sfFormExtraPlugin
,
which contains validators, widgets, and forms which
cannot be included with the main symfony package as they are too specific or
have external dependencies but are nonetheless very useful.
Install the plugin with the plugin:install
task:
$ php symfony plugin:install sfFormExtraPlugin
Or via Subversion with the following command:
$ svn co http://svn.symfony-project.org/plugins/sfFormExtraPlugin/branches/1.3/ plugins/sfFormExtraPlugin
In order for plugin's classes to be loaded, the sfFormExtraPlugin
plugin must
be activated in the config/ProjectConfiguration.class.php
file as shown below:
// config/ProjectConfiguration.class.php public function setup() { $this->enablePlugins(array( 'sfDoctrinePlugin', 'sfDoctrineGuardPlugin', 'sfFormExtraPlugin' )); }
note
The sfFormExtraPlugin
contains widgets that require external dependencies
like JavaScript libraries. You will find a widget for rich date selectors,
one for a WYSIWYG editor, and much more. Take the time to read the
documentation as you will find a lot of useful stuff.
The sfFormExtraPlugin
plugin provides a sfFormLanguage
form to manage the
language selection. Adding the language form can be done in the layout like
this:
note
The code below is not meant to be implemented. It is here to show you how you might be tempted to implement something in the wrong way. We will go on to show you how to implement it properly using symfony.
// apps/frontend/templates/layout.php <div id="footer"> <div class="content"> <!-- footer content --> <?php $form = new sfFormLanguage( $sf_user, array('languages' => array('en', 'fr')) ) ?> <form action="<?php echo url_for('change_language') ?>"> <?php echo $form ?><input type="submit" value="ok" /> </form> </div> </div>
Do you spot a problem? Right, the form object creation does not belong to the View layer. It must be created from an action. But as the code is in the layout, the form must be created for every action, which is far from practical.
In such cases, you should use a component. A component is like a
partial but with some code attached to it. Consider it as a lightweight action.
Including a component from a template can be done by using the
include_component()
helper:
// apps/frontend/templates/layout.php <div id="footer"> <div class="content"> <!-- footer content --> <?php include_component('language', 'language') ?> </div> </div>
The helper takes the module and the action as arguments. The third argument can be used to pass parameters to the component.
Create a language
module to host the component and the action that will
actually change the user language:
$ php symfony generate:module frontend language
Components are to be defined in the actions/components.class.php
file.
Create this file now:
// apps/frontend/modules/language/actions/components.class.php class languageComponents extends sfComponents { public function executeLanguage(sfWebRequest $request) { $this->form = new sfFormLanguage( $this->getUser(), array('languages' => array('en', 'fr')) ); } }
As you can see, a components class is quite similar to an actions class.
The template for a component uses the same naming convention as a partial would:
an underscore (_
) followed by the component name:
// apps/frontend/modules/language/templates/_language.php <form action="<?php echo url_for('change_language') ?>"> <?php echo $form ?><input type="submit" value="ok" /> </form>
As the plugin does not provide the action that actually changes the user
culture, edit the routing.yml
file to create the change_language
route:
# apps/frontend/config/routing.yml change_language: url: /change_language param: { module: language, action: changeLanguage }
And create the corresponding action:
// apps/frontend/modules/language/actions/actions.class.php class languageActions extends sfActions { public function executeChangeLanguage(sfWebRequest $request) { $form = new sfFormLanguage( $this->getUser(), array('languages' => array('en', 'fr')) ); $form->process($request); return $this->redirect('localized_homepage'); } }
The process()
method of sfFormLanguage
takes care of changing the user
culture, based on the user form submission.
Internationalization
Languages, Charset, and Encoding
Different languages have different character sets. The English language is the simplest one as it only uses the ASCII characters, the French language is a bit more complex with accentuated characters like "é", and languages like Russian, Chinese, or Arabic are much more complex as all their characters are outside the ASCII range. Such languages are defined with totally different character sets.
When dealing with internationalized data, it is better to use the unicode norm. The idea behind unicode is to establish a universal set of characters that contains all characters for all languages. The problem with unicode is that a single character can be represented with as many as 21 octets. Therefore, for the web, we use UTF-8, which maps Unicode code points to variable-length sequences of octets. In UTF-8, most used languages have their characters coded with less than 3 octets.
UTF-8 is the default encoding used by symfony, and it is defined in the
settings.yml
configuration file:
# apps/frontend/config/settings.yml all: .settings: charset: utf-8
Also, to enable the internationalization layer of symfony, you must set the i18n
setting to true
in settings.yml
:
# apps/frontend/config/settings.yml all: .settings: i18n: true
Templates
An internationalized website means that the user interface is translated into several languages.
In a template, all strings that are language dependent must be wrapped with the
__()
helper (notice that there is two underscores).
The __()
helper is part of the I18N
helper group, which contains helpers
that ease i18n management in templates. As this helper group is not loaded by
default, you need to either manually add it in each template with
use_helper('I18N')
as we already did for the Text
helper group, or load it
globally by adding it to the standard_helpers
setting:
# apps/frontend/config/settings.yml all: .settings: standard_helpers: [Partial, Cache, I18N]
Here is how to use the __()
helper for the Jobeet footer:
// apps/frontend/templates/layout.php <div id="footer"> <div class="content"> <span class="symfony"> <img src="/legacy/images/jobeet-mini.png" /> powered by <a href="/"> <img src="/legacy/images/symfony.gif" alt="symfony framework" /></a> </span> <ul> <li> <a href=""><?php echo __('About Jobeet') ?></a> </li> <li class="feed"> <?php echo link_to(__('Full feed'), 'job', array('sf_format' => 'atom')) ?> </li> <li> <a href=""><?php echo __('Jobeet API') ?></a> </li> <li class="last"> <?php echo link_to(__('Become an affiliate'), 'affiliate_new') ?> </li> </ul> <?php include_component('language', 'language') ?> </div> </div>
note
The __()
helper can take the string for the default language or you can
also use a unique identifier for each string. It is just a matter of taste.
For Jobeet, we will use the former strategy so templates are more readable.
When symfony renders a template, each time the __()
helper is called, symfony
looks for a translation for the current user's culture. If a translation is
found, it is used, if not, the first argument is returned as a fallback value.
All translations are stored in a catalogue. The i18n framework provides a lot of different strategies to store the translations. We will use the "XLIFF" format, which is a standard and the most flexible one. It is also the store used by the admin generator and most symfony plugins.
note
Other catalogue stores are gettext
, MySQL
, and SQLite
. As always, have
a look at the i18n API for
more details.
i18n:extract
Instead of creating the catalogue file by hand, use the built-in i18n:extract
task|I18n Extraction Task:
$ php symfony i18n:extract frontend fr --auto-save
The i18n:extract
task finds all strings that need to be translated in fr
in
the frontend
application and creates or updates the corresponding catalogue.
The --auto-save
option saves the new strings in the catalogue. You can also
use the --auto-delete
option to automatically remove strings that do not exist
anymore.
In our case, it populates the file we have created:
<!-- apps/frontend/i18n/fr/messages.xml --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE xliff PUBLIC "-//XLIFF//DTD XLIFF//EN" "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd"> <xliff version="1.0"> <file source-language="EN" target-language="fr" datatype="plaintext" original="messages" date="2008-12-14T12:11:22Z" product-name="messages"> <header/> <body> <trans-unit id="1"> <source>About Jobeet</source> <target/> </trans-unit> <trans-unit id="2"> <source>Feed</source> <target/> </trans-unit> <trans-unit id="3"> <source>Jobeet API</source> <target/> </trans-unit> <trans-unit id="4"> <source>Become an affiliate</source> <target/> </trans-unit> </body> </file> </xliff>
Each translation is managed by a trans-unit
tag which has a unique id
attribute. You can now edit this file and add translations for the French
language:
<!-- apps/frontend/i18n/fr/messages.xml --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE xliff PUBLIC "-//XLIFF//DTD XLIFF//EN" "http://www.oasis-open.org/committees/xliff/documents/xliff.dtd"> <xliff version="1.0"> <file source-language="EN" target-language="fr" datatype="plaintext" original="messages" date="2008-12-14T12:11:22Z" product-name="messages"> <header/> <body> <trans-unit id="1"> <source>About Jobeet</source> <target>A propos de Jobeet</target> </trans-unit> <trans-unit id="2"> <source>Feed</source> <target>Fil RSS</target> </trans-unit> <trans-unit id="3"> <source>Jobeet API</source> <target>API Jobeet</target> </trans-unit> <trans-unit id="4"> <source>Become an affiliate</source> <target>Devenir un affilié</target> </trans-unit> </body> </file> </xliff>
tip
As XLIFF is a standard format, a lot of tools exist to ease the translation process. Open Language Tools is an Open-Source Java project with an integrated XLIFF editor.
tip
As XLIFF is a file-based format, the same precedence and merging rules that exist for other symfony configuration files are also applicable. I18n files can exist in a project, an application, or a module, and the most specific file overrides translations found in the more global ones.
Translations with Arguments
The main principle behind internationalization is to translate whole sentences. But some sentences embed dynamic values. In Jobeet, this is the case on the homepage for the "more..." link:
<!-- apps/frontend/modules/job/templates/indexSuccess.php --> <div class="more_jobs"> and <?php echo link_to($count, 'category', $category) ?> more... </div>
The number of jobs is a variable that must be replaced by a placeholder for translation:
<!-- apps/frontend/modules/job/templates/indexSuccess.php --> <div class="more_jobs"> <?php echo __('and %count% more...', array('%count%' => link_to($count, 'category', $category))) ?> </div>
The string to be translated is now "and %count% more...", and the %count%
placeholder will be replaced by the real number at runtime, thanks to the value
given as the second argument to the __()
helper.
Add the new string manually by inserting a trans-unit
tag in the
messages.xml
file, or use the i18n:extract
task to update the file
automatically:
$ php symfony i18n:extract frontend fr --auto-save
After running the task, open the XLIFF file to add the French translation:
<trans-unit id="6"> <source>and %count% more...</source> <target>et %count% autres...</target> </trans-unit>
The only requirement in the translated string is to use the %count%
placeholder somewhere.
Some other strings are even more complex as they involve plurals. According to some numbers, the sentence changes, but not necessarily the same way for all languages. Some languages have very complex grammar rules for plurals, like Polish or Russian.
On the category page, the number of jobs in the current category is displayed:
<!-- apps/frontend/modules/category/templates/showSuccess.php --> <strong><?php echo count($pager) ?></strong> jobs in this category
When a sentence has different translations according to a number, the
format_number_choice()
helper should be used:
<?php echo format_number_choice( '[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category', array('%count%' => '<strong>'.count($pager).'</strong>'), count($pager) ) ?>
The format_number_choice()
helper takes three arguments:
- The string to use depending on the number
- An array of placeholders
- The number to use to determine which text to use
The string that describes the different translations according to the number is formatted as follow:
- Each possibility is separated by a pipe character (
|
) - Each string is composed of a range followed by the translation
The range can describe any range of numbers:
[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 values greater or equal to negative infinity and strictly less than 0{n: n % 10 > 1 && n % 10 < 5}
: Matches numbers like 2, 3, 4, 22, 23, 24
Translating the string is similar to other message strings:
<trans-unit id="7"> <source>[0]No job in this category|[1]One job in this category|(1,+Inf]%count% jobs in this category</source> <target>[0]Aucune annonce dans cette catégorie|[1]Une annonce dans cette catégorie|(1,+Inf]%count% annonces dans cette catégorie</target> </trans-unit>
Now that you know how to internationalize all kind of strings, take the time to
add __()
calls for all templates of the frontend application. We won'tt
internationalize the backend application.
Forms
The form classes contain many strings that need to be translated, like labels, error messages, and help messages. All these strings are automatically internationalized by symfony, so you only need to provide translations in the XLIFF files.
note
Unfortunately, the i18n:extract
task does not yet parse form classes for
untranslated strings.
Doctrine Objects
For the Jobeet website, we won't internationalize all tables as it does not make sense to ask the job posters to translate their job posts in all available languages. But the category table definitely needs to be translated.
The Doctrine plugin supports i18n tables out of the box. For each table that contains localized data, two tables need to be created: one for columns that are i18n-independent, and the other one with columns that need to be internationalized. The two tables are linked by a one-to-many relationship.
Update the schema.yml
|schema.yml
(I18n) accordingly:
# config/doctrine/schema.yml JobeetCategory: actAs: Timestampable: ~ I18n: fields: [name] actAs: Sluggable: { fields: [name], uniqueBy: [lang, name] } columns: name: { type: string(255), notnull: true }
By turning on the I18n
behavior, a model named JobeetCategoryTranslation
will be automatically created and the specified fields
are moved to that
model.
Notice we simply turn on the I18n
behavior and move the Sluggable
behavior
to be attached to the JobeetCategoryTranslation
model which is automatically
created. The uniqueBy
option tells the Sluggable
behavior which fields
determine whether a slug is unique or not. In this case each slug must be unique
for each lang
and name
pair.
And update the fixtures for categories:
# data/fixtures/categories.yml JobeetCategory: design: Translation: en: name: Design fr: name: design programming: Translation: en: name: Programming fr: name: Programmation manager: Translation: en: name: Manager fr: name: Manager administrator: Translation: en: name: Administrator fr: name: Administrateur
We also need to override the findOneBySlug()
method in JobeetCategoryTable
.
Since Doctrine provides some magic finders for all columns in a model, we need
to simply create the findOneBySlug()
method so that we override the default
magic functionality Doctrine provides.
We need to make a few changes so that the category is retrieved based on the
english slug in the JobeetCategoryTranslation
table.
// lib/model/doctrine/JobeetCategoryTable.cass.php public function findOneBySlug($slug) { $q = $this->createQuery('a') ->leftJoin('a.Translation t') ->andWhere('t.lang = ?', 'en') ->andWhere('t.slug = ?', $slug); return $q->fetchOne(); }
Rebuild the model:
$ php symfony doctrine:build --all --and-load --no-confirmation $ php symfony cc
tip
As the doctrine:build --all --and-load
removes all tables and data from the
database, don't forget to re-create a user to access the Jobeet backend with
the guard:create-user
task. Alternatively, you can add a fixture file to add
it automatically for you.
When using the I18n
behavior, proxies are created between the JobeetCategory
object and the JobeetCategoryTranslation
object so all the old functions for
retrieving the category name will still work and retrieve the value for the
current culture.
$category = new JobeetCategory(); $category->setName('foo'); // sets the name for the current culture $category->getName(); // gets the name for the current culture $this->getUser()->setCulture('fr'); // from your actions class $category->setName('foo'); // sets the name for French echo $category->getName(); // gets the name for French
tip
To reduce the number of database requests, join the
JobeetCategoryTranslation
in your queries. It will retrieve the main object
and the i18n one in one query.
$categories = Doctrine_Query::create() ->from('JobeetCategory c') ->leftJoin('c.Translation t WITH t.lang = ?', $culture) ->execute();
The WITH
keyword above will append a condition to the automatically added
ON
condition of the query. So, the ON
condition of the join will end up
being.
LEFT JOIN c.Translation t ON c.id = t.id AND t.lang = ?
As the category
route is tied to the JobeetCategory
model class and
because the slug
is now part of the JobeetCategoryTranslation
, the route is
not able
to retrieve the Category
object automatically. To help the routing system,
let's create a method that will take care of object retrieval:
Since we already overrode the findOneBySlug()
let's refactor a little bit more
so these methods can be shared. We'll create a new findOneBySlugAndCulture()
and doSelectForSlug()
methods and change the findOneBySlug()
method to
simply use the findOneBySlugAndCulture()
method.
// lib/model/doctrine/JobeetCategoryTable.class.php public function doSelectForSlug($parameters) { return $this->findOneBySlugAndCulture($parameters['slug'], $parameters['sf_culture']); } public function findOneBySlugAndCulture($slug, $culture = 'en') { $q = $this->createQuery('a') ->leftJoin('a.Translation t') ->andWhere('t.lang = ?', $culture) ->andWhere('t.slug = ?', $slug); return $q->fetchOne(); } public function findOneBySlug($slug) { return $this->findOneBySlugAndCulture($slug, 'en'); }
Then, use the method
option|method
option (Routing) to tell the category
route to use the doSelectForSlug()
method to retrieve the object:
# apps/frontend/config/routing.yml category: url: /:sf_culture/category/:slug.:sf_format class: sfDoctrineRoute param: { module: category, action: show, sf_format: html } options: { model: JobeetCategory, type: object, method: doSelectForSlug } requirements: sf_format: (?:html|atom)
We need to reload the fixtures to regenerate the proper slugs for the categories:
$ php symfony doctrine:data-load
Now the category
route is internationalized and the URL for a category embeds
the translated category slug:
/frontend_dev.php/fr/category/programmation /frontend_dev.php/en/category/programming
Admin Generator
For the backend, we want the French and the English translations to be edited in the same form:
Embedding an i18n form can be done by using the embedI18N()
method:
// lib/form/JobeetCategoryForm.class.php class JobeetCategoryForm extends BaseJobeetCategoryForm { public function configure() { unset( $this['jobeet_affiliates_list'], $this['created_at'], $this['updated_at'] ); $this->embedI18n(array('en', 'fr')); $this->widgetSchema->setLabel('en', 'English'); $this->widgetSchema->setLabel('fr', 'French'); } }
The admin generator interface supports internationalization out of the box. It
comes with translations for more than 20 languages, and it is quite easy to add
a new one, or to customize an existing one. Copy the file for the language you
want to customize from symfony (admin translations are to be found in
lib/vendor/symfony/lib/plugins/sfDoctrinePlugin/i18n/
) in the application
i18n
directory. As the file in your application will be merged with the
symfony one, only keep the modified strings in the application file.
You will notice that the admin generator translation files are named like
sf_admin.fr.xml
, instead of fr/messages.xml
. As a matter of fact, messages
is the name of the default catalogue used by symfony, and can be changed to
allow a better separation between different parts of your application. Using a
catalogue other than the default one requires that you specify it when using the
__()
helper:
<?php echo __('About Jobeet', array(), 'jobeet') ?>
In the above __()
call, symfony will look for the "About Jobeet" string in the
jobeet
catalogue.
Tests
Fixing tests is an integral part of the internationalization
migration. First, update the test fixtures for categories by copying the
fixtures we have
define above in test/fixtures/categories.yml
.
Don't forget to update methods in the lib/test/JobeetTestFunctional.class.php
file in order to care of our modifications concerning the JobeetCategory
's
internationalization.
public function getMostRecentProgrammingJob() { $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->leftJoin('c.Translation t') ->where('t.slug = ?', 'programming'); $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q); return $q->fetchOne(); }
Rebuild the model for the test
environment:
$ php symfony doctrine:build --all --and-load --no-confirmation --env=test
You can now launch all tests to check that they are running fine:
$ php symfony test:all
note
When we have developed the backend interface for Jobeet, we have not written functional tests. But whenever you create a module with the symfony command line, symfony also generate test stubs. These stubs are safe to remove.
Localization
Templates
Supporting different cultures also means supporting different way to format dates and numbers. In a template, several helpers are at your disposal to help take all these differences into account, based on the current user culture:
In the Date
helper group:
Helper | Description |
---|---|
format_date() |
Formats a date |
format_datetime() |
Formats a date with a time (hours, minutes, seconds) |
time_ago_in_words() |
Displays the elapsed time between a date and now in words |
distance_of_time_in_words() |
Displays the elapsed time between two dates in words |
format_daterange() |
Formats a range of dates |
In the Number
helper
group:
Helper | Description |
---|---|
format_number() |
Formats a number |
format_currency() |
Formats a currency |
In the I18N
helper group:
Helper | Description |
---|---|
format_country() |
Displays the name of a country |
format_language() |
Displays the name of a language |
Forms (I18n)
The form framework provides several widgets and validators for localized data:
sfWidgetFormI18nDate
sfWidgetFormI18nDateTime
sfWidgetFormI18nChoiceCurrency
sfWidgetFormI18nChoiceLanguage
sfValidatorI18nChoiceLanguage
sfValidatorI18nChoiceTimezone
Final Thoughts
Internationalization and localization are first-class citizens in symfony. Providing a localized website to your users is very easy as symfony provides all the basic tools and even gives you command line tasks to make it fast.
Be prepared for a very special day as we will be moving a lot of files around and exploring a different approach to organizing a symfony project.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.