p28n, the top to bottom persistent internationalization tutorial.

by p0windah
For some developers, allowing a website to support multiple languages is essential. Luckily cakePHP 1.2 has the foundations available to make this possible.
Before forging ahead, I'd like a disclaimer. I don't claim this tutorial to be uniquely mine, it's an amalgamation of techniques from several pages and sites. Neither is it the most comprehensive and in-depth guide.

That said, I certainly hope after using this guide you can quickly and easily implement multiple languages in your cake app without needing to skip around the place. If I fall short of this and you have suggestions, leave a comment.

Once you complete this tutorial your site will be able to:
  1. display multiple languages
  2. allow users to switch languages
  3. store language settings in cookies, so returning visitors don't need to re-select their preferred language
The sites that I build typically require 3 languages:
  1. British English(en-gb)
  2. Simplified Chinese(zh-cn)
  3. Traditional Chinese(zh-tw)
So throughout this document, I'll be using them as my reference languages. Your site may support more or less languages.

Step 1: Setup the directories for your messages

$ cd cake/app/locale/

$ mkdir en_gb

$ mkdir en_gb/LC_MESSAGES

$ mkdir zh_tw

$ mkdir zh_tw/LC_MESSAGES

$ mkdir zh_cn

$ mkdir zh_cn/LC_MESSAGES

This will create the minimum folders required for each language our site needs to support.

To find your language code(s) refer to http://api.cakephp.org/1.2/l10n_8php-source.html#l00180 //this is only a sample. don't add this to your code.
'nl' => array(
    'language' => 'Dutch (Standard)', 
    'locale' => 'dut', 
    'localeFallback' => 'dut', 
    'charset' => 'utf-8'
),
'pl' => array(
    'language' => 'Polish', 
    'locale' => 'pol', 
    'localeFallback' => 'pol', 
    'charset' => 'utf-8'
),
'sk' => array(
    'language' => 'Slovack', 
    'locale' => 'slo', 
    'localeFallback' => 'slo', 
    'charset' => 'utf-8'
),
By studying the sample above, we can see that directory names are actually the locale:
  • 'dut' is the correct directory name(locale) for 'nl'
  • 'pol' is the correct directory name(locale) for 'pl'
  • 'slo' is the correct directory name(locale) for 'sk'

Step 2: Write some strings to translate.

View Template:

<?php $this->pageTitle __('pageTitle_home'true); ?>

<h1><?php __('welcome_heading'); ?></h1>
<?php __('lipsum'); ?><?php __('lipsum'); ?><?php __('lipsum'); ?><br/>
<?php __('lipsum'); ?><?php __('lipsum'); ?><?php __('lipsum'); ?><br/>
<?php __('footer_copyright'); ?>
note: The 2nd parameter controls whether a message should be returned or echo'd
So when you are working with a template, use:

PHP Snippet:

<?php __('my_name');?> And when you are working with code, use:

PHP Snippet:

<?php $name __('my_name'true);?>

Step 3: Let's build a database from our PHP and templates.

$ cd cake/app/

$ cake extract

This will recursively go through all the folders and check both your .php and .ctp files for all of those __() functions you typed. Once it's complete, you should have a nice message template file named default.pot file inside cake/app/locale/

So let's copy this message template file into the right directories for each language.

$ cd cake/app/locale/

$ cp default.pot locale/en_gb/LC_MESSAGES/default.po

$ cp default.pot locale/zh_tw/LC_MESSAGES/default.po

$ cp default.pot locale/zh_cn/LC_MESSAGES/default.po


note: at this point in time, you can freely edit the default.po files(they're just text) and start translating strings. Changes made to these files will automatically be rendered in your views.

Here are some short snippets from my default.po files.

// locale/zh_cn/LC_MESSAGES/default.po
msgid "footer_copyright"
msgstr "教育局 © 2007. 版权所有"

// locale/zh_tw/LC_MESSAGES/default.po
msgid "footer_copyright"
msgstr "教育局 © 2007. 版權所有"

// locale/en_gb/LC_MESSAGES/default.po
msgid "footer_copyright"
msgstr "Education Bureau © 2007. All rights reserved."

Step 4: Change the default language

A fresh install of cakePHP is set to use American English, so for the rest of us: we need that changed.
// config/bootstrap.php
define(DEFAULT_LANGUAGE, 'zh-tw');

Step 5: Let users change the language

Component Class:

<?php 
class P28nComponent extends Object {
    var 
$components = array('Session''Cookie');

    function 
startup() {
        if (!
$this->Session->check('Config.language')) {
            
$this->change(($this->Cookie->read('lang') ? $this->Cookie->read('lang') : DEFAULT_LANGUAGE));
        }
    }

    function 
change($lang null) {
        if (!empty(
$lang)) {
            
$this->Session->write('Config.language'$lang);
            
$this->Cookie->write('lang'$langnull'+350 day'); 
        }
    }
}
?>
Thanks Nasko for pointing out that Cookie->write() does not accept timestamps

Controller Class:

<?php 
class P28nController extends AppController {
    var 
$name 'P28n';
    var 
$uses null;
    var 
$components = array('P28n');

    function 
change($lang null) {
        
$this->P28n->change($lang);

        
$this->redirect($this->referer(nulltrue));
    }

    function 
shuntRequest() {
        
$this->P28n->change($this->params['lang']);

        
$args func_get_args();
        
$this->redirect("/" implode("/"$args));
    }
}
?>

Controller Class:

<?php 
//app_controller.php
class AppController extends Controller {
    var 
$components = array('P28n');
}
?>
The final piece of code, are some custom routes that need to be added to cake/app/config/routes.php
<?php
//route to switch locale
Router::connect('/lang/*', array('controller' => 'p28n''action' => 'change'));

//forgiving routes that allow users to change the lang of any page
Router::connect('/eng?/*', array(
    
'controller' => "p28n",
    
'action' => "shuntRequest",
    
'lang' => 'en-gb'
));

Router::connect('/zh[_-]tw/*', array(
    
'controller' => "p28n",
    
'action' => "shuntRequest",
    
'lang' => 'zh-tw'
));

Router::connect('/zh[_-]cn/*', array(
    
'controller' => "p28n",
    
'action' => "shuntRequest",
    
'lang' => 'zh-cn'
));
?>

Step 6: Links to change language

View Template:

<h1><?php __('welcome_heading'); ?></h1>
<?php __('lipsum'); ?><?php __('lipsum'); ?><?php __('lipsum'); ?><br/>
<?php __('lipsum'); ?><?php __('lipsum'); ?><?php __('lipsum'); ?><br/>
<?php __('footer_copyright'); ?>

<!-- these links will change the language, but allow the user to stay on this page //-->
<?php echo $html->link($html->image('en_gb.gif'), '/lang/en-gb'nullnullfalse); ?>
<?php 
echo $html->link($html->image('zh_tw.gif'), '/lang/zh-tw'nullnullfalse); ?>
<?php 
echo $html->link($html->image('zh_cn.gif'), '/lang/zh-cn'nullnullfalse); ?>

<!-- these links will change the language, then forward the user to the /news page //-->
<?php echo $html->link($html->image('en_gb.gif'), '/en-gb/news'nullnullfalse); ?>
<?php 
echo $html->link($html->image('zh_tw.gif'), '/zh-tw/news'nullnullfalse); ?>
<?php 
echo $html->link($html->image('zh_cn.gif'), '/zh-cn/news'nullnullfalse); ?>

Step 7: All done.

Assuming I have included all the right code and not forgotten anything, you should now be fully functional^^

Further reading

A popular cross-platform GUI tool for managing .po files is poEdit http://www.poedit.net/ Gettext and cakePHP supports much more than word for word, literal translations. Check out __(), __c(), __d(), __dc(), __dcn(), __dn(), __n() http://api.cakephp.org/1.2/basics_8php.html
..and finally, remember that utf-8 is your friend. treat it well, and it'll reciprocate.

Report

More on Tutorials

Advertising

Comments

  • lorebett posted on 05/16/11 12:08:49 PM
    now my main question is: how to make this work together with caching?
    Anyone succeeded?

    thanks in advance
  • lorebett posted on 05/16/11 12:07:08 PM
    Sorry, my mistake: instead of

    class P28nComponent extends Object {

    I did

    class P28nComponent extends Component {

    and then some inherited methods were reflectively invoked which spoiled the url.
  • lorebett posted on 05/13/11 08:11:13 PM
    Hi
    I tried to use this code, but If do something like
    http://localhost/mycakeapp/lang/eng
    I get a cyclic link error
    If I try
    http://localhost/mycakeapp/eng/mycontroller/myaction
    I get
    Undefined index: lang [APP/controllers/p28n_controller.php, line 20]
    has anyone succeeded in using this code please?
  • efgpinto posted on 01/28/11 08:26:59 AM
    Someone could tell me about special chars like "ç" or "ã" at .po files?

    When I use them at msgstr, even if the file is with UTF-8 encoding, that does not work...

    Solutions?
  • blackbelt posted on 09/07/10 05:14:07 AM
    hello everybody and thanks for the useful guide. I ve followed it step without results. Where can I start to investigate in order to understand What's I made wrong?
  • elvis posted on 03/21/10 04:00:13 AM
    I have got a problem with google can not index my webpages. How to solve this problem? like friend url.
  • Logico posted on 02/03/10 09:54:37 AM
    This is all very nice, but what about if I later want to add new content to my site? How can I edit text without having to generate a default.pot file all over again and therefore, having to translate everything all over again.

    Any help would be appreciated.

    • Logico posted on 02/05/10 08:04:38 PM
      This is all very nice, but what about if I later want to add new content to my site? How can I edit text without having to generate a default.pot file all over again and therefore, having to translate everything all over again.

      Any help would be appreciated.


      Nevermind... just needed the command line to extract and poedit to update.
  • thedilab posted on 08/19/09 02:06:09 AM
    This is a very useful .

    However I am doing this in a more essential level

    http://www.the-di-lab.com/?p=72
  • Hurart posted on 03/02/09 05:16:46 AM
    Your post is very good!
    May I ask you one little question?

    I have defalut.po, view-1.po and view-2.po files.

    There are some common translations in default.po
    view-1.po and view-2.po have some conflicting translations, I have to separate them.

    Now I use function _d() in my programs for every i18n design.

    I hope view-1.po and view-2.po just do their special translations.
    How can view-1.po or view-2.po include default.po for the common items?

    Thank you very much.
  • lioremen posted on 01/14/09 08:50:01 PM
    I just need it
  • limule posted on 08/03/08 03:08:03 AM
    Here is a way to use a .po file for each set of view of for each view :

    app/app_controller.php :
    ---
    class AppController extends Controller {
    ...
    var $domain = 'default';
    ...
    function beforeFilter() {
    ...
    $this->domain = 'default';
    }

    function beforeRender(){
    ...
    $this->set('domain', $this->domain);
    }
    }

    In app/controllers/xxx_controller.php :
    class XxxController extends AppController {

    function yyy($index = 0) {
    ...
    $this->set('domain', '-views-xxxs-yyy');
    ...
    }
    }

    In app/views/xxxs/yyy.ctp :
    ...
    __d($domain, "SENTENCE_TO_TRANSLATE");
    ...

    In app/locale/eng/LC_MESSAGES/-views-xxxs-yyy.po :
    ...
    msgid "SENTENCE_TO_TRANSLATE"
    msgstr "sentence translated"
    ...

    In app/locale/fre/LC_MESSAGES/-views-xxxs-yyy.po :
    ...
    msgid "SENTENCE_TO_TRANSLATE"
    msgstr "phrase traduite"
    ...

    • hernanc posted on 10/29/09 01:57:29 PM
      I've found that the "cake i18n" console command creates multiple files this way for a given controller ("News" in this example):
      -controllers-news_controller.pot
      -views-news-add.pot
      -views-news-edit.pot
      (And so on for each News action)

      All of which you have to rename to .po

      In /app/app_controller.php, add these lines (or modify your existing methods accordingly):

      function beforeFilter(){
          //Sets the domain to the current controller automatically
          $this->domain = '-controllers-' . low($this->name) . '_controller';
      }

      function beforeRender(){
          //Sets the domain to the current view for the current controller automatically
          $this->set('domain', '-views-' . low($this->name) . '-' . $this->action);
      }
      Just remember that if you have controller-specific beforeFilter and beforeRender you need to add the parent::beforeFilter and parent::parent::beforeController somewhere in them.
  • riverside posted on 06/23/08 09:57:34 AM
    To change my URLs I use this:

    class P28nController extends AppController {
        var $name = 'P28n';
        var $uses = null;
        var $components = array('P28n');

        function change($lang = null) {
            $this->P28n->change($lang);
            $url = $this->referer(null, true);
            switch ($lang)
            {
                case 'bul':
                    $url = preg_replace('/\/[bg|en]{2}\//', '/bg/', $url, 1);
                    break;
                case 'eng':
                    $url = preg_replace('/\/[bg|en]{2}\//', '/en/', $url, 1);
                    break;
            }        
            $this->redirect($url, null, true);
        }

        function shuntRequest() {
            $this->P28n->change($this->params['lang']);
            $args = func_get_args();
            $this->redirect("/" . implode("/", $args), null, true);
        }
    }

    But I'm wondering how can I modify my links easily around the whole my site? Now I use the regular HTML helper: $html->link('title', array('controller' => 'some', 'action' => 'doThat'));
  • ticler posted on 05/31/08 11:01:05 PM
    The following will generate an undefined index notice.

    // config/bootstrap.php
    define("DEFAULT_LANGUAGE", 'en-ca'); 

    It should use the three letter fallback language code, like

    // config/bootstrap.php
    define("DEFAULT_LANGUAGE", 'eng'); 
  • piza posted on 05/13/08 06:15:17 AM
    default.po files are very usefull.
    However, it would be nice to be able to use a .po file by view. It would be more optimized and clearer.
    How do it? An idea?
  • rostislav posted on 03/27/08 03:07:43 AM
    The localized string like __('lorem') in Controller:: beforeFilter() will make language switch malfunction.
  • eimermusic posted on 03/14/08 10:35:32 AM
    I had a lot of problems trying to get this to work until I switched off all caching in Cake. i18n caches the whole .po-file and doesn't update very often.

    I would love to have a more specific way to do this but when writing localizations and checking them in the browser uncomment this line in config/core.php

    Configure::write('Cache.disable', true);


  • flip posted on 03/02/08 12:58:42 PM

    Model Class:

    <?php 
    class P28nController extends AppController {
        var 
    $uses null;
    ?>

    I highly recommend applying an emtpy array instead of NULL.

    ;)
  • piza posted on 02/26/08 04:32:07 AM
    It works fine for me replacing in bootstrap.php:
    define(DEFAULT_LANGUAGE, 'xxxx');
    by
    define("DEFAULT_LANGUAGE", 'xxxx');
  • hellomimi posted on 02/08/08 12:33:19 PM
    I am using Beta: 1.2.0.6311.

    Problem 1.

    In cake/lib/l10n.php, line 376 (caused by the fix: https://trac.cakephp.org/ticket/3382), it has:

    $this->languagePath[2] = $this->__l10nCatalog[$this->__l10nMap[$this->default]]['localeFallback'];

    because zh-tw is not defined in the __l10nMap, it causes problem. In fact, because the __l10nMap table does not have the en_us record. It will fail with all the sub locale as well.

    Problem 2.

    I put the following in app_controller.php:
    var $components = array('P28n');

    the UI output:
    warning: Cannot modify header information - headers already sent by (output started at C:\cygwin\home\terence\src\buzi\trunk\src\cakephp\app\controllers\components\p28n.php:20) [CORE\cake\libs\controller\components\cookie.php, line 384]
    Code | Context

    $name = "[lang]"
    $value = "en"


    Warning: implode() [function.implode]: Invalid arguments passed in C:\cygwin\home\terence\src\buzi\trunk\src\cakephp\cake\libs\debugger.php on line 497

    setcookie - [internal], line ??
    CookieComponent::__write() - CORE\cake\libs\controller\components\cookie.php, line 384
    CookieComponent::write() - CORE\cake\libs\controller\components\cookie.php, line 233
    P28nComponent::change() - APP\controllers\components\p28n.php, line 15
    P28nComponent::startup() - APP\controllers\components\p28n.php, line 8
    Dispatcher::start() - CORE\cake\dispatcher.php, line 319
    Dispatcher::dispatch() - CORE\cake\dispatcher.php, line 226
    [main] - APP\webroot\index.php, line 84

    look like something is output some thing before it can put the cookie in the header...

    • cristi posted on 05/05/08 06:16:03 PM
      Check out if you have a space after the closing ?> tag.

      ...
      Problem 2.

      I put the following in app_controller.php:
      var $components = array('P28n');

      the UI output:
      warning: Cannot modify header information - headers already sent by (output started at ...)

  • raphaele posted on 01/18/08 04:48:21 AM
    Search indexing problem can easily been solved by adding virtal directories and url rewritng transforming any /en/ into an ending ?lang=en and overriding the $html->link so it outputs any link with a begining /en/ if en is the current language or implementing a helper that does the same job without touching to $html->link.
    but i really wonder after reading this article how you do to translate your database data ? it seems it is available and i have that impression listening to the dedicated show though my poor english doesn't allow me to get the how-to...
    i only need a kick-off here: what's the beginning of it ? how do i start to be able to translate the data in my tables ?
    anyway thanks for the great job !!! cakephp rocks.
  • msti posted on 01/10/08 11:00:19 AM
    My problem was that I did not change the name of default.pot to default.po Now it works fine.

    Thanks for the great tutorial.
  • kirez posted on 01/03/08 09:53:23 AM
    Hi everything goes ok and it runs without error, but locale is not changed when i click on lang/xx links. Looking at the code, i see it writes new setting to cookie and session, but where does it set the new value for the default_language?
  • Prokur posted on 12/19/07 01:49:18 PM
    bash commands:
    $ ./cake i18n
    or
    $ ./cake extract

    how to run those commands under Windows?
  • andyhuax posted on 11/28/07 04:02:41 AM
    Hi everyone,Why I can not use the follow script:
    $ cake extract
    The error is "-bash: cake: command not found"
    Can someone help me.thanks so much.
    • mguezuraga posted on 12/13/07 06:55:22 AM
      Hi everyone,Why I can not use the follow script:
      $ cake extract
      The error is "-bash: cake: command not found"
      Can someone help me.thanks so much.

      On linux and cake 1.2 its:
      $ ./cake i18n

      I think in 1.1 was:
      $ ./cake extract

      Hope it helps
  • jeffsmith posted on 11/05/07 05:08:59 PM
    I'd just like to echo chris' comment above regarding dynamic content. Wondering if there is anything in the pipeline for us to look forward to pulling translated labels from a DB?
  • cguimont posted on 10/10/07 09:15:06 AM
    Yes, it won't read cookies from what I know. But ne thiung is that using the session to pass the language to the page can be a good idea and the system is able to read the entire page. After that, you may ajust your link to keep the language in the url??
    Maybe modifying the html->link function could be an idea.
    Also, what I did is that I published a sitemap with all languages link, like this google will treat them as different pages and will index it's content!


    • excieve posted on 10/10/07 09:28:37 AM
      Of course in terms of usability it is a good idea to pass the locale through session. Sitemap will help to keep pages in the index but I doubt that pages with non-default locales will receive much link weight especially from external pages. So there should be some kind of URL-based handler in order to keep the site optimized.
  • elsigh posted on 10/09/07 08:41:15 PM
    Is there any way to use something like poedit to make life a little easier with this scheme?
  • cguimont posted on 10/07/07 03:59:57 PM
    Hi,
    The routing can make some problems with some robots as Googlebot since when you change language, you get a redirect to the exact same page as you had but in another language ( transparent for the user). Since google will see that you try to redirect to the same page that he already indexed the content, he won't charge page, and he won't index the other language content.
    I would strongly suggest that instead of using the route:
    Router::connect('/eng?/*', array(
    'controller' => "p28n",
    'action' => "shuntRequest",
    'lang' => 'en-gb'
    ));
    you use something like :
    $Route->connect('/en-gb/:controller/:action/*', array('lang' => 'en-gb'));
    and inside your app controler have this function:

    function beforeFilter(){
    if(isset($this->params['lang'])){
    $this->Session->write('Config.language', $this->params['lang']);
    }
    }
    Like this, google will interprete this as a nother page and will index the content.

    • excieve posted on 10/10/07 09:09:57 AM
      Correct me if I'm wrong, but I don't think that Googlebot (or most other crawlers) understands cookies... and as such it won't understand sessions. So in fact, all it sees is the page with default language in that case. There should also be a handler in the pages controller to parse language code in the URL and show correct version of the page with all links pointing to pages with the right localization.
  • Floriam posted on 10/07/07 11:39:09 AM
    After reading this tutorial I was a bit confused where the language was actually changed, as it's a bit hidden in the code.

    As far as I can see there are 2 possiblities to change the language:


    <?php
    Configure
    ::write('Config.language''fre');
    ?>
    This will change the language just for the current page.


    <?php 
    $this
    ->Session->write('Config.language''eng'); 
    ?>
    This will change the language for the current user. This setting has a higher priority than Configure::write(), so changing the language in this way would overwrite the code posted above.
  • pixol posted on 09/21/07 02:10:56 PM
    This looks very nice, but this is just static content, how would one go about saving dynamic content in different languages ?
  • Phillip posted on 09/21/07 10:43:11 AM
    1.) The comments in the code snippets from the po files are *NOT* legal comments. The comment delimiter for po files afaict is "#:", maybe with a required leading unix newline. Do *not* use the shown "//" at the beginning of a comment line!

    2.) There is no explaination given why localisation names diverge so much, even vertically across the stack (zh_cn vs. zh-cn or en_us vs. eng or deu vs. de_de) The default application stack lacks consitency here, newbies need to be aware of that and establish their own conventions if the need arises.

    3.) The tutorial mostly just offers codesnippets and hardly says where to put what - newbies will have to look up the Cake filename/classdirectory convetions. As this tutorial is for 1.2 but the manual only covers 1.1 (as of now Sebtember 2007) there are a few differences to expect.

    4.) Everything under /app/view/ (templates) uses '.ctp' as ending as of CakePHP version 1.2. Trying to figure out CakePHP templating standards using this tutorial and the manual can be confusing, keep the above in mind.

    5.) The 'en_gb.gif' files (presumably images of flags)
    don't exist within the framework and it isn't said where they belong. I presume studying the class(es) used to instance the $html object or studying the manual may bring some insight here.

    6.) Bottom line: This is NOT a tutorial for people who aren't safe in navigating around in unknow application frameworks and learning their methodologies. Nor is it for those who are easyly confused by object oriented concepts. The scarcity of detail information in this tutorial can only be met by doing own further research on CakePHP! ... However it does give a nice strategy for dealing with persistant internationalisation for those willing to dive into the details to get it working for their application.
  • nasko posted on 09/12/07 06:12:45 PM
    Jason, this article just couldn't have come at a more appropriate time. I've been avoiding the tackling of the i18n aspect of my current project for the past week in favor of more urgent tasks. It was only today when I started digging around for tutorial, case studies or sample code. Enter the P28n tutorial. Well organized and to the point. Thank you!

    Thanks Nasko for pointing out that Cookie->write() does not accept timestamps
    It's not that difficult to get confused about this IMHO. Looking at the synopsis of CookieComponent::write():

    Parameters:

    mixed $key

    mixed $value

    boolean $encrypt

    string $expires public

    To me the term expires suggests a fixed point in time, so it's normal for someone to initially try passing a timestamp rather than a delta. When I initially implemented your code I didn't even get uncomfortable about passing a timestamp. It was only when my cookie didn't get set to expire when it was supposed to when I started looking through the sources.

    Anyways, once again thanks for the nice write-up!
login to post a comment.