SEO-conform Multilingual URLs + Language Selector Widget (i18n)

21 followers

You have a multilingual application, and you want the URL of a page to be different for different languages, to account for SEO. The URL for the contact page for example should look like http://something.com/en/contact in english, and http://something.com/de/contact in german. This tutorial describes how to make it happen.
Note that currently selected language is always a part of the URL, and thus available to the application through $_GET. You don't have to use sessions variables to keep track of the language if you don't want to.

The code is mainly from this article in spanish, to which I added some tweaks (thanks to sebas for the link).
Tip: If you don't want/need the language to be part of the URL, take a look at this article. That method may be preferrable if SEO is not an issue for your application (e.g. backend-type applications for authorized users only), since it is simpler.

Now to the code...

1. Extend the CUrlManager

Create the file 'components/UrlManager.php' with the content:

<?php
class UrlManager extends CUrlManager
{
    public function createUrl($route,$params=array(),$ampersand='&')
    {
        if (!isset($params['language'])) {
            if (Yii::app()->user->hasState('language'))
                Yii::app()->language = Yii::app()->user->getState('language');
            else if(isset(Yii::app()->request->cookies['language']))
                Yii::app()->language = Yii::app()->request->cookies['language']->value;
            $params['language']=Yii::app()->language;
        }
        return parent::createUrl($route, $params, $ampersand);
    }
}
?>

For this approach to work, the language has to be a part of the URL. That is, $_GET['language'] has to be defined. To ensure this, we override the createUrl() function of the class. If the desired language is not present in the URL, we look if it is stored in a session variable, or in a cookie and set the application language accordingly. After that, we add the language to the parameters of the URL.
For the sake of completeness:The array $params contains the part of the URL that comes after the '?', that is the GET parameters, as key-value pairs. For http://something.com/controller/action?language=en&id=1, $params would be equal to array('language'=>'en', 'id'=>1).

2. Edit your Controller

Add the following code to 'components/Controller.php':

<?php
public function __construct($id,$module=null){
    parent::__construct($id,$module);
    // If there is a post-request, redirect the application to the provided url of the selected language 
    if(isset($_POST['language'])) {
        $lang = $_POST['language'];
        $MultilangReturnUrl = $_POST[$lang];
        $this->redirect($MultilangReturnUrl);
    }
    // Set the application language if provided by GET, session or cookie
    if(isset($_GET['language'])) {
        Yii::app()->language = $_GET['language'];
        Yii::app()->user->setState('language', $_GET['language']); 
        $cookie = new CHttpCookie('language', $_GET['language']);
        $cookie->expire = time() + (60*60*24*365); // (1 year)
        Yii::app()->request->cookies['language'] = $cookie; 
    }
    else if (Yii::app()->user->hasState('language'))
        Yii::app()->language = Yii::app()->user->getState('language');
    else if(isset(Yii::app()->request->cookies['language']))
        Yii::app()->language = Yii::app()->request->cookies['language']->value;
}
public function createMultilanguageReturnUrl($lang='en'){
    if (count($_GET)>0){
        $arr = $_GET;
        $arr['language']= $lang;
    }
    else 
        $arr = array('language'=>$lang);
    return $this->createUrl('', $arr);
}
?>

We extend the constructor method of our controller class, in which we set the application language. Since all our invidiual controller classes extend from this one, the application language will be set explicitly upon each request.
Note: If we don't set Yii::app()->language explicitly for each request, it will be equal to its default value set in the confg file. If it is not set in the config file, it will be equal to the value Yii::app()->sourceLanguage, which defaults to 'en_us'.
You can set default values for the language and sourceLanguage of your application in your config file with

'sourceLanguage'=>'en',
'language'=>'de',

3. Build a Language Selector Widget

Create the file 'components/widgets/LanguageSelector.php' with the content:

<?php
class LanguageSelector extends CWidget
{
    public function run()
    {
        $currentLang = Yii::app()->language;
        $languages = Yii::app()->params->languages;
        $this->render('languageSelector', array('currentLang' => $currentLang, 'languages'=>$languages));
    }
}
?>

Create the file 'components/widgets/views/languageSelector.php' with the content:

<div id="language-select">
<?php 
    if(sizeof($languages) < 4) {
        // Render options as links
        $lastElement = end($languages);
        foreach($languages as $key=>$lang) {
            if($key != $currentLang) {
                echo CHtml::link(
                     $lang, 
                     $this->getOwner()->createMultilanguageReturnUrl($key));
            } else echo '<b>'.$lang.'</b>';
            if($lang != $lastElement) echo ' | ';
        }
    }
    else {
        // Render options as dropDownList
        echo CHtml::form();
        foreach($languages as $key=>$lang) {
            echo CHtml::hiddenField(
                $key, 
                $this->getOwner()->createMultilanguageReturnUrl($key));
        }
        echo CHtml::dropDownList('language', $currentLang, $languages,
            array(
                'submit'=>'',
            )
        ); 
        echo CHtml::endForm();
    }
?>
</div>

If the number of available languages is smaller than four, the languages are displayed as links, separated with a '|'. Otherwise a dropDownList is generated.
Note: When this post-request is processed in Controller.php (see above), the application will be redirected to one of the urls provided via hidden fields in this form, because at that point in time, the controller id will not exist and the call to $this->createUrl('') will throw an error.

4. Put the Widget on your Site

Add the following code into the header-div in 'views/layouts/main.php'

<div  id="language-selector" style="float:right; margin:5px;">
    <?php 
        $this->widget('application.components.widgets.LanguageSelector');
    ?>
</div>

5. Edit your Config File

Apply the following changes/additions to the file 'config/main.php':

<?php
'components'=>array(
    ...
    'request'=>array(
        'enableCookieValidation'=>true,
        'enableCsrfValidation'=>true,
    ),
    'urlManager'=>array(
        'class'=>'application.components.UrlManager',
        'urlFormat'=>'path',
        'showScriptName'=>false,
        'rules'=>array(
            '<language:(de|tr|en)>/' => 'site/index',
            '<language:(de|tr|en)>/<action:(contact|login|logout)>/*' => 'site/<action>',
            '<language:(de|tr|en)>/<controller:\w+>/<id:\d+>'=>'<controller>/view',
            '<language:(de|tr|en)>/<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
            '<language:(de|tr|en)>/<controller:\w+>/<action:\w+>/*'=>'<controller>/<action>',
        ),
    ),
),
'params'=>array(
    'languages'=>array('tr'=>'Türkçe', 'en'=>'English', 'de'=>'Deutsch'),
),
?>

We declare our new class 'UrlManager' as the class to be used by the urlManager component, and prefix <language:(de|tr|en)>/ to the keys of our rules array.

That's it. If something is unclear, wrong or incomplete, please let me know.

Total 14 comments

#8006 report it
eli3b at 2012/05/03 06:02am
thank you

thank you, the tutorial is very clear!

#7193 report it
Ballamann at 2012/03/02 10:52am
@c@cba

Yeah the problem was, my htaccess-file was replaced by my Aptana with a simple "deny from all". I replaced this wrong htaccess with the one I posted on #7173 and now everything works fine :)

#7185 report it
c@cba at 2012/03/01 06:32pm
@Ballamann

hmm.. that is strange.. I tried to reproduce the error: I get Error 404 : Unable to resolve the request "de/contact" when I delete the rule that matches this url, namely:

'<language:(de|tr|en)>/<action:(contact|login|logout)>/*' => 'site/<action>',

which tells the application to actually call site/contact.

Can you confirm the following two points:

  • You have a file .htaccess in your main application directory with the content
RewriteEngine on

# if a directory or a file exists, use it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

# otherwise forward it to index.php
RewriteRule . index.php

so that your urls do not contain any 'index.php' or similar...

  • When the first rule in your rules array is
'rules'=>array(
    '<action:(contact|login|logout)>/*' => 'site/<action>',...
)

then the url somthing.com/contact opens the contact page.

#7173 report it
Ballamann at 2012/03/01 04:12am
404 =/

I actually simply made Copy&Paste. My config.php has this:

'urlManager'=>array(
            'class'=>'application.components.UrlManager',
 
            'urlFormat'=>'path',
 
            'showScriptName'=>false,
 
            'urlSuffix'=>'.htm',
 
            'rules'=>array(
 
'<language:(de|tr|en)>/' => 'site/index',
 
'<language:(de|tr|en)>/<action:(contact|login|logout)>/*' => 'site/<action>',
 
'<language:(de|tr|en)>/<controller:\w+>/<id:\d+>'=>'<controller>/view',
 
'<language:(de|tr|en)>/<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
 
'<language:(de|tr|en)>/<controller:\w+>/<action:\w+>/*'=>'<controller>/<action>',
            ),
        ),

And no, "/de/site/page/view/about" leads to a 404 too.

Still thanks for your attention so far ;)

A little update: If i say "'showScriptName'=>true" its working! But it doesnt look to good with "index.php/de/something.htm" if you find the reason i'd be glad to read from you =)

And another Update: To all of you with same problem, check your .htaccess! It has to look like this:

Options +FollowSymLinks
IndexIgnore */*
RewriteEngine on
 
# if a directory or a file exists, use it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
 
# otherwise forward it to index.php
RewriteRule . index.php

Mine is working fine now, thx to c@cba!

#7169 report it
c@cba at 2012/02/29 11:11pm
@Ballamann

I'm not sure where the problem could be, without more information.
How do your rules of the urlManager (in config/main.php) look like?
Does the rule that applies to "/de/site/page/view/about" direct the application to the correct controller/action?

#7168 report it
Ballamann at 2012/02/29 01:00pm
404 =/

Hm... sounds really nice, but I get 404 Errors when implementing this... Not even an error of an missing view, but totally 404. If I disable the pathformat it "works" (I have the language-parameter). Any Idea why?

My Urls look like this:

/de/site/page/view/about

/de/contact

#7107 report it
c@cba at 2012/02/23 06:59pm
@abennouna

Thank you for your encouraging words and for the tip!

#7106 report it
abennouna at 2012/02/23 06:51pm
Do not display the current language

Great tutorial c@cba! Straight to the point and well explained.

If we prefer to display only the other available languages, and not the current one that is used, we could slightly modify languageSelector.php into:

if(sizeof($languages) < 4) {
    $i=1;
    // Render options as links
    foreach($languages as $key=>$lang) {
        if($key != $currentLang) {
            echo CHtml::link(
                 $lang, 
                 $this->getOwner()->createMultilanguageReturnUrl($key));
            if(++$i < count($languages)) echo ' | ';
        }
    }
} else {
  ()
  // no change here, the dropdown will contain all languages
}
#6369 report it
c@cba at 2012/01/03 03:26pm
Re: url

This happened to me, when I didn't have the rules in the right order. They should be in the following order:

'<language:(de|tr|en)>/<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',
'<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',
'<action:(contact|login|logout)>/*' => 'site/<action>',

The following order leads to the problem you mentioned:

'<action:(contact|login|logout)>/*' => 'site/<action>',
'<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',
'<language:(de|tr|en)>/<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',

Keep in mind: Yii checks the rules for matching patterns, starting from the beginning. As soon as something is matched, Yii doesn't look further.

To make sure: are the country and locations names, controller names? Is '/en/spain/barcelona' for example supposed to call the controller Spain.php? The above approach does not work, if you want translation in controller and action names. If you want the action name 'publictransport' translated to spanish for example, you need a different solution. This article could be helpful.

#6364 report it
SymonSays at 2012/01/03 11:04am
url

Thank you so much for giving me a hand with this. I applied your code but I am not sure if it is working the right way. When I click on spanish for example it directs to: /site/index/language/es/country/espana/location/barcelona and before it was not like that it was /es/ without /site/index/languages and then if I click English it goes to the same link and doesn't translate. Do you have any idea how to solve this.

And btw for example I want to create a page about public transport let's say in spain/barcelona, then the have to create a module and an action for example /spain/barcelona/publictransport/?

Thanks again for your help!

Symon

#6347 report it
c@cba at 2011/12/31 06:13pm
location in url

The following should work:
1. Change the method createMultilanguageReturnUrl():

public function createMultilanguageReturnUrl($lang='en', $country='spain', $location='barcelona'){
    if (count($_GET)>0){
        $arr = $_GET;
        $arr['language']= $lang;
        $arr['country'] = Yii::t('locations', $country, array(), null, $arr['language']);
        $arr['location'] = Yii::t('locations', $location, array(), null, $arr['language']);
    }
    else {
        $arr = array(
            'language'=>$lang, 
            'country'=>Yii::t('locations', $country, array(), null, $arr['language']),
            'location'=>Yii::t('locations', $location, array(), null, $arr['language']),
        );
    }
    if($lang == 'en') unset($arr['language']); 
 
    return $this->createUrl('', $arr);
}

2. Edit the rules in the config file, for every page, there should be two versions:

'<language:(de|tr)>/<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',
'<country:\w+>/<location:\w+>/<action:(contact|login|logout)>/*' => 'site/<action>',

3. In UrlManager.php, the mothod createUrl() should be like:

public function createUrl($route,$params=array(),$ampersand='&')
{
    if (!isset($params['country'])) $params['country'] = Yii::t('locations','spain');
    if (!isset($params['location'])) $params['location'] = Yii::t('locations','barcelona');
    return parent::createUrl($route, $params, $ampersand);
}

4. Create the file protected/messages/de/locations.php with the content:

<?php
return array(
    'spain'=>'spanien',
    'barcelona'=>'barcelona',
);
?>

Under protected/messages, you'll need a directory for each language of your application with the same file containig the relative translations of course.

Hope this helps.
Best regards...

#6344 report it
SymonSays at 2011/12/31 10:41am
@c@cba

Thank you so much it works like a charm. I am quite new to Yii but learning a lot this way. Because I would like to load the content for a certain language, so they click on spanish and the pages show the spanish content and now I think I am able to do that.

Because I am quite new to this I have a question regarding the URL and params, now with the language there I would also like to add locations and cities so for spanish the url could look something like: /es/espana/barcelona and english would stay /spain/barcelona (countries and locations loaded from DB) do I have to set this up in the config and the syntax would be something similar to the language?

Thanks!

#6335 report it
c@cba at 2011/12/30 03:59pm
@SymonSays

Hi Symon, thanks for the encouragement.
I just tested the following, and it did wirk for me:
1. First I discarded all the code where I check and adopt the language in session and/or cookie. Because you want the language to be english if the user enters .com/contact and the language is not part of the url.
If there would be a cookie with language='de', the above code would use it to render the page .com/contact in german anyway.

2. Add the following line to the method createMultilanguageReturnUrl($lang='en') in Controller.php, just before the return line:

if($lang == 'en') unset($arr['language']);

3. The mothod __construct in Controller.php should look like this:

public function __construct($id,$module=null){
        parent::__construct($id,$module);
        if(isset($_POST['language'])) {
            $lang = $_POST['language'];
            $this->redirect($_POST[$lang]);
        }
        if(isset($_GET['language'])) {
            Yii::app()->language = $_GET['language'];
        }
        else Yii::app()->language = 'en';
    }

4. You can discard the class UrlManager completely. Or you can just comment out all the code for testing (except return parent::createUrl($route, $params, $ampersand);), and if everything works for sure, just delete the file and remove this corresponding line in the config file:
'class'=>'application.components.UrlManager',

5. In your config file, for each rule prefixed with <language:(de|tr|en)>/, you should have the same rule without the prefix. It should look something like this (note that <language:(de|tr|en) is now <language:(de|tr)):

'rules'=>array(
    '<language:(de|tr)>/' => 'site/index',
    '<language:(de|tr)>/<controller:\w+>/<id:\d+>'=>'<controller>/view',
    '<language:(de|tr)>/<controller:\w+>/<action:\w+>/*'=>'<controller>/<action>',
    '' => 'site/index',
    '<controller:\w+>/<id:\d+>'=>'<controller>/view',
    '<controller:\w+>/<action:\w+>/*'=>'<controller>/<action>',
),

This should hopefully work...

#6329 report it
SymonSays at 2011/12/30 09:30am
Nice

Thank you, I was looking for a way to work with multiple languages and this works out great. I just have a question:

I would like to show my site in English if they hit the .com and for example in Spanish if the click on Spanish which in the URL would be com/es. In the Spanish site if they click the English language it goes to the .com again without language. How would I be able to do that with this script?

Thanks, keep up the good work!

Symon

Leave a comment

Please to leave your comment.