Yii 1.1: multilingual-behavior

Easily create and handle translated / i18n / multilingual models with this behavior
33 followers

This behavior allow you to create multilingual models and to use them (almost) like normal models. For each model, translations have to be stored in a separate table of the database (ex: PostLang or ProductLang), which allow you to easily add or remove a language without modifying your database.

First example: by default translations of current language are inserted in the model as normal attributes.

// Assuming current language is english (in protected/config/main.php : 'sourceLanguage' => 'en')
$model = Post::model()->findByPk((int) $id);
echo $model->title; //echo "English title"
 
//Now let's imagine current language is french (in protected/config/main.php : 'sourceLanguage' => 'fr')
$model = Post::model()->findByPk((int) $id);
echo $model->title; //echo "Titre en Français"
 
$model = Post::model()->localized('en')->findByPk((int) $id);
echo $model->title; //echo "English title"
 
//Here current language is still french

Second example: if you use multilang() in a "find" query, every translation of the model are loaded as virtual attributes (title_en, title_fr, title_de, ...).

$model = Post::model()->multilang()->findByPk((int) $id);
echo $model->title_en; //echo "English title"
echo $model->title_fr; //echo "Titre en Français"

Requirements

Yii 1.1 or above

Usage

Here an example of base 'post' table :

CREATE TABLE IF NOT EXISTS `post` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `title` varchar(255) NOT NULL,
    `content` TEXT NOT NULL,
    `created_at` datetime NOT NULL,
    `updated_at` datetime NOT NULL,
    `enabled` tinyint(1) NOT NULL DEFAULT '1',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

And his associated translation table (configured as default), assuming translated fields are 'title' and 'content':

CREATE TABLE IF NOT EXISTS `postLang` (
    `l_id` int(11) NOT NULL AUTO_INCREMENT,
    `post_id` int(11) NOT NULL,
    `lang_id` varchar(6) NOT NULL,
    `l_title` varchar(255) NOT NULL,
    `l_content` TEXT NOT NULL,
    PRIMARY KEY (`l_id`),
    KEY `post_id` (`post_id`),
    KEY `lang_id` (`lang_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
 
ALTER TABLE `postLang`
ADD CONSTRAINT `postlang_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;

Attach this behavior to the model (Post in the example). Everything that is commented is with default values.

public function behaviors() {
    return array(
        'ml' => array(
            'class' => 'application.models.behaviors.MultilingualBehavior',
            //'langClassName' => 'PostLang',
            //'langTableName' => 'postLang',
            //'langForeignKey' => 'post_id',
            //'langField' => 'lang_id',
            'localizedAttributes' => array('title', 'content'), //attributes of the model to be translated
            //'localizedPrefix' => 'l_',
            'languages' => Yii::app()->params['translatedLanguages'], // array of your translated languages. Example : array('fr' => 'Français', 'en' => 'English')
            'defaultLanguage' => Yii::app()->params['defaultLanguage'], //your main language. Example : 'fr'
            //'createScenario' => 'insert',
            //'localizedRelation' => 'i18nPost',
            //'multilangRelation' => 'multilangPost',
            //'forceOverwrite' => false,
            //'forceDelete' => true, 
            //'dynamicLangClass' => true, //Set to true if you don't want to create a 'PostLang.php' in your models folder
        ),
    );
}

Look into the behavior source code and read the comments of each attribute to understand how to configure them and how to use the behavior.

In order to retrieve translated models by default, add this function in the model class:

public function defaultScope()
{
    return $this->ml->localizedCriteria();
}

You also can modify the loadModel function of your controller to minimize the changes to make in your controller:

public function loadModel($id, $ml=false) {
    if ($ml) {
        $model = Post::model()->multilang()->findByPk((int) $id);
    } else {
        $model = Post::model()->findByPk((int) $id);
    }
    if ($model === null)
        throw new CHttpException(404, 'The requested post does not exist.');
    return $model;
}

and use it like this in the update action :

public function actionUpdate($id) {
    $model = $this->loadModel($id, true);
    ...
}

Here is a very simple example for the form view : 

<?php foreach (Yii::app()->params['translatedLanguages'] as $l => $lang) :
    if($l === Yii::app()->params['defaultLanguage']) $suffix = '';
    else $suffix = '_'.$l;
    ?>
<fieldset>
    <legend><?php echo $lang; ?></legend>
 
    <div class="row">
    <?php echo $form->labelEx($model,'title'); ?>
    <?php echo $form->textField($model,'title'.$suffix,array('size'=>60,'maxlength'=>255)); ?>
    <?php echo $form->error($model,'title'.$suffix); ?>
    </div>
 
    <div class="row">
    <?php echo $form->labelEx($model,'content'); ?>
    <?php echo $form->textArea($model,'content'.$suffix); ?>
    <?php echo $form->error($model,'content'.$suffix); ?>
    </div>
</fieldset>
<?php endforeach; ?>

To enable search on translated fields, you can modify the search() function in the model like this :

public function search()
{
    $criteria=new CDbCriteria;
 
    //...
    //here your criteria definition
    //...
 
    return new CActiveDataProvider($this, array(
        'criteria'=>$this->ml->modifySearchCriteria($criteria),
        //instead of
        //'criteria'=>$criteria,
    ));
}

Warning: the modification of the search criteria is based on a simple str_replace so it may not work properly under certain circumstances.

It's also possible to retrieve languages translation of two or more related models in one query. Example for a Page model with a "articles" HAS_MANY relation : 

$model = Page::model()->multilang()->with('articles', 'articles.multilangArticle')->findByPk((int) $id);
echo $model->articles[0]->content_en;

With this method it's possible to make multi model forms like it's explained here

History

24/03/2012: First release

28/03/2012: It's now possible to modify language when retrieving data with the localized relation.

Example:

$model = Post::model()->localized('en')->findByPk((int) $id);

30/03/2012: Correction for the after save method.

26/04/2012 Modification of the rules definition for translated attributes:

  • if you set forceOverwrite to true, every rules defined in the model for the attributes to translate will be applied to the translations.

  • if you set forceOverwrite to false (default), every rules defined in the model for the attributes to translate will be applied to the translations except "required" rules that will only be applied to the default translation.

28/06/2012 **** Bug fix **** two2wyes found and fixed a bug that prevented translations to be correctly saved on attributes that only have a "required" rule and with the "forceOverwrite" option set to false. Thanks again to him. See the thread

Resources

Authors

Many thanks to guillemc who made the biggest part of the work on this behavior (see original thread).

Total 20 comments

#15929 report it
rajesh chaurasia at 2013/12/30 11:43pm
Nice

nice extension i was looking for this.

#15849 report it
teo_ne at 2013/12/22 09:22am
The table "{{postLand}}" for active record class "PostLang" cannot be found in the database.

@mahmoud khafagy

You can easily solve this issue by setting 'tablePrefix' => '', in your db connection setting in main.php

This can cause some trouble with other modules that use tbl_prefix

Hope it Help!

#15344 report it
mahmoud khafagy at 2013/10/30 10:47am
The table "{{pay_grades_lang}}" for active record class "PayGradesLang" cannot be found in the database.

i have two tables pay_grades,pay_grades_lang but when i create object new PayGrade from any place this error is happen The table "{{pay_grades_lang}}" for active record class "PayGradesLang" cannot be found in the database.

how can i fixed it ?

#14489 report it
morteza at 2013/08/17 12:43am
problem gridview

I have this 2 models
1. User (multi language)
2. UserMessage

In UserMessage model I have:

public function relations()
    {
        return array(
            'user' => array(self::BELONGS_TO, 'User', 'user_id'),
                    'sendUser' => array(self::BELONGS_TO, 'User', 'send_user_id'),
        );
    }
 
public function search()
    {
....................
 
$criteria->with = array('user','sendUser');
        $criteria->compare('user.username',$this->user_search,true);
        $criteria->compare('sendUser.username',$this->sendUser_search,true);
 
...................
}

problem in gridview "UserMessage":

CDbCommand failed to execute the SQL statement: SQLSTATE[42000]: Syntax error or access violation: 1066 Not unique table/alias: 'i18nUser'. The SQL statement executed was: SELECT COUNT(DISTINCT `t`.`id`) FROM `user_message` `t` LEFT OUTER JOIN `user` `user` ON (`t`.`user_id`=`user`.`id`) LEFT OUTER JOIN `userLang` `i18nUser` ON (`i18nUser`.`user_id2`=`user`.`id`) AND (i18nUser.language='en') LEFT OUTER JOIN `user` `sendUser` ON (`t`.`send_user_id`=`sendUser`.`id`) LEFT OUTER JOIN `userLang` `i18nUser` ON (`i18nUser`.`user_id2`=`sendUser`.`id`) AND (i18nUser.language='en') WHERE (t.user_id=2)

Thank you

#14418 report it
savoo at 2013/08/10 02:58am
error

'db'=>array( 'connectionString' => 'mysql:host=localhost;dbname=rcsalg', 'emulatePrepare' => true, 'username' => 'root', 'password' => '', 'charset' => 'utf8', //'tablePrefix'=>'', 'tablePrefix'=>'x_' ),

i use tableprefix in my website

and i'm getting this error please any solution

The relation "i18nCategory" in active record class "Category" is specified with an invalid foreign key "category_id". There is no such column in the table "x_categorylang".

#11221 report it
Backslider at 2012/12/28 12:11pm
New Extension Uploaded

Taking up Fred's challenge, I have released a new language extension that behaves in the way I have described.

Validation was in fact best done using afterValidate().

http://www.yiiframework.com/extension/yii-language-behavior/

#11198 report it
Backslider at 2012/12/25 10:23pm
New Extension Coming. Merry Christmas!

I have a behavior working, as I have described. That is, all translatable columns are now in their own language table, without any mixing of attributes with the main table.

I just need to complete validation, which can be done using beforeSave() within the behavior. Its possible to merge errors from two different models, so it should be simple enough. Once I have completed testing I will release it.

#11177 report it
Backslider at 2012/12/23 02:27am
Dynamically Setting Application Languages

This is an example of how I set languages in my applications. I have this in a behavior class which extends CBehavior within my components directory:

public function beginRequest()
    {
        if(!isset(Yii::app()->params['languages'])) {
            $languages = Language::model()->findAll();
            $language_array = array();
            foreach($languages as $val) {
                $language_array[$val->id] = $val->name;
                if($val->default == 1) Yii::app()->params['defaultLanguage'] = $val->id;
            }
            Yii::app()->params['languages'] = $language_array;
        }
 
        // user changes the language
        if(isset($_GET['language'])) {
            if(array_key_exists($_GET['language'], Yii::app()->params['languages'])) {
                Yii::app()->setLanguage($_GET['language']);
            }
        }
    }
#11176 report it
Backslider at 2012/12/23 12:44am
Re. I don't think that having a flag in the translations table

Of course not, it should be in the languages table... that was just a brain fart :-)

I look forward to seeing it on Github.

#11175 report it
fredpeaks at 2012/12/22 09:42pm
Re: Re: you're just another troll

@Backslider: If you don't want to be called troll, please avoid the words "shame" and "flawed". If it's not to offend, I don't see the point of using these words. Maybe they have another sense for you than for me because English is not my language. In that case, sorry for the misunderstanding.

As I've said before, ideas and contributions are always welcome. I see that you're a big poster on the Yii forum so I can imagine that you use the framework often and know a lot of things about it. Certainly a lot more than me because I don't really use Yii. The only "usage" I had is my small contribution to this extension and other small things I've made in order to make the framework usable by colleagues in my company. And it was 8 month ago. So you probably see technical solutions to these problems that I don't see.

The purpose of duplicating the translated attributes in the main table is not to store the default language values but to have attributes in the model with rules and everything. But, as I said, I'm not a fan of this solution. So if you think about another one, please share.

The default language is defined by the configuration of the behavior. I don't think that having a flag in the translations table is good because the flag would be duplicated for every translated entity and harder to modify that a simple string in the configuration. And if you choose to store your application's languages in a table, the default flag should be in that table.

For most of the application, storing the application's languages in the configuration is sufficient, that's why it's the solution presented in the doc. But I understand that there are use cases where you want to have a languages table specially when you want to be able to manage them using a backend (add/remove languages, store extra data like a flag image or even translate the language label). And of course, in that case, languages data should be cached to avoid extra queries, with sessions or even better the Yii cache system.

About the Github repo, I've been contacted by another member of the forum (thyandrecardoso) who offers to create it. I've accepted that and I'm waiting for his answer to update the page. But if you want to do it or help him, you can contact him to organize that.

#11174 report it
Backslider at 2012/12/22 06:56pm
Re: you're just another troll

Fred, I think you are being somewhat precious. You essentially agree with my "criticism", yet wish to label me a troll? Those comments are designed solely to push development toward a better solution, not to offend, so sorry if they did. I even said it was "really nice". As far as I am concerned, my comments are a contribution - I am contributing my own knowledge.

Yii models do not have any problem with composite keys - its only if you try to generate CRUD that there is an issue. Since the postlang table does not require CRUD, there is no issue.

Clearly you are almost there with this extension and I really don't see why you chose to duplicate columns in the main table for the default language where a simple flag in postlang would suffice.

I think that a separate language table is more robust, its far simpler to add languages (particularly for the end user) and its easy to store language specific data. Its simple enough to retrieve this data on application startup and keep it in $_SESSION, so only one query.

Good that you are putting it on Github if you don't plan to work on it further. I'd be more than happy to fork and improve on it.

#11173 report it
fredpeaks at 2012/12/22 02:45pm
Re: Nice work, shame about the design

@Backslider: How about you developing a version of the extension without "flawed design" and giving it to the community?

Because if all you can make is write about others works to describe already known problems without trying to contribute to the solution, you're just another troll...

First problem, the translated columns in the main table: Yes you're right, but this is not easy to do with Yii models. Feel free to provide a solution (with some code).

Second problem, the primary key should be composite and not integer: I agree with you but as for the first problem, this is not easy to do with Yii models. Same thing, show us your code.

Third problem, make a language table: if you had read the doc and try the extension, you would already know that the languages are in the main config file of the application. So yes they are centralized without having to make additional queries to retrieve them. But it's also possible for anyone to store the languages in a database table because the languages are passed to the extension in the configuration.

I've got a good news for you and everyone else: the extension will be put on github so that everyone can contribute easily to it. I don't use Yii anymore so I won't be able to maintain it. I will update the extension page to point to the github repository as soon as it will be available.

#11172 report it
Backslider at 2012/12/22 11:24am
Nice work, shame about the design

This is a really nice attempt at multi language support, however its fundamentally flawed in design.

The post table should not have the columns 'title' and 'content'. Instead, these columns should be in the postlang table. Sharing the same data type between the two tables is very bad design. What if I want to change my default language?

'postlang' should not have an auto_increment 'l_id' column. Instead, 'post_id' and 'lang_id' should be a composite primary key.

It should also include a languages table, otherwise where are you controlling available languages in the application? In this case 'lang_id' should be an integer referencing this table.

#10770 report it
C.S.Putera at 2012/11/22 09:54am
Bug with PostgreSQL 9.1

Hi, thanks for such a great extension. But I found this error when using PostgreSQL 9.1 :

CDbCommand failed to execute the SQL statement: SQLSTATE[42P01]: Undefined table: 7 ERROR: missing FROM-clause entry for table "i18nannualpurchase"
LINE 1: ...nnual_purchase_id"="t"."annual_purchase_id") AND (i18nAnnual...
^. The SQL statement executed was: SELECT COUNT(DISTINCT "t"."annual_purchase_id") FROM "tbl_annual_purchase" "t" LEFT OUTER JOIN "tbl_annual_purchase_lang" "i18nAnnualPurchase" ON ("i18nAnnualPurchase"."annual_purchase_id"="t"."annual_purchase_id") AND (i18nAnnualPurchase.lang_id='en_us')

It is because there is no quotation before and after the localizedRelation. Fixed by changing line 259 into :

$owner->getMetaData()->relations[$this->localizedRelation] = new $class($this->localizedRelation, $this->langClassName, $this->langForeignKey, array('on' => '"'.$this->localizedRelation.'"' . "." . $this->langField . "='" . $lang . "'", 'index' => $this->langField));

Note : Adding quotation mark before and after $this->localizedRelation

#10529 report it
dianakwt at 2012/11/02 09:32am
find query with localized attribute

Hi!

In your examples you load a model usinf findByPk, but what if I need to load a model by a localized attribute (ex. url defined in db).

Page::model()->->find('url=:title',
array(
':title'=>$_GET['title'],
));

In my default language it works ok, but in translated one, it should refer to l_url instead of url, and it throws a 404 error.

What do I have to do in that case?

Thanks!

Diana.

#10512 report it
lucaMS at 2012/11/01 10:01am
Duplicated records

On the insert or update of a record, the record for the default language it's saved in "table" and in "table_lang"

Reading the documentation i thinked that the record for the main language it's saved only in the main table, not in table_lang, isn't it?

Where i wrong?

Thanks for this fantastic class!

#10326 report it
samuel.m at 2012/10/19 10:58am
Virtual attributes

For those like me who don't have the localized fields in the main table but only in the localized one ( if keeping this exemple Post don't have title and content ), then you have to add the fields ( with same name than PostLang) in your Model Class as public properties .

class Post extends CActiveRecord {
 
  public $l_title;
  public $l_content;
 
  ...
}

By the way it's not a good practice to have duplicate fields on differents table, also not a good practive to use camelCase ( using underscore is better ) for table name as some Database Server Like Postgres are not case sensitive for table names.

#8458 report it
fredpeaks at 2012/06/05 01:20pm
Re: Another problem... original model relations

@sirfaber: Hi! You should retrieve your product model like this (technicalDatas objects in product will have all translations inside) :

$product  = Product::model()->multilang()->with('technicalDatas', 'technicalDatas.multilangTechnicalData')->findByPk((int) $id);

or if you want to keep the "foreach" to retrieve translations (but it will make more db queries for the same result) :

foreach ($model->technicalDatas as $techData)
{
  $temp = TechnicalData::model()->multilang()->findByPk($techData->id);
  [...]
}
#8456 report it
sirfaber at 2012/06/05 11:34am
Another problem... original model relations

I have this 2 models:

  • Product
  • TechnicalData

Both are multilanguage.

In Product model I have:

public function relations()
{
  return array(
    'technicalDatas' => array(self::HAS_MANY, 'TechnicalData', 'product_id'),
  );
}

In "views/product/view.php" I'm trying to show all linked TechnicalData objects with all translated fields (actually this is an administrator page not for public). My code is:

foreach ($model->technicalDatas as $techData)
{
  $temp = $techData->multilang();
  [...]
}

The problem is that $temp doesn't have any <field>_<lang> attribute...

I took a look into sql query log and I noticed that is retrived just "localized" data (ie my actual choosen language in the example is spanish):

SELECT `technicalDatas`.`id` AS `t1_c0`, `technicalDatas`.`product_id` AS `t1_c1`, `technicalDatas`.`description` AS `t1_c2`, `technicalDatas`.`units` AS `t1_c3`, `technicalDatas`.`value` AS `t1_c4`,
[...]
, `i18nTechnicalData`.`l_id` AS `t2_c0`, `i18nTechnicalData`.`technical_data_id` AS `t2_c1`, `i18nTechnicalData`.`lang_id` AS `t2_c2`, `i18nTechnicalData`.`l_description` AS `t2_c3`, `i18nTechnicalData`.`l_units` AS `t2_c4`, `i18nTechnicalData`.`l_value` AS `t2_c5`, 
[...]
FROM `tbl_technical_data` `technicalDatas`  LEFT OUTER JOIN `tbl_technical_data_lang` `i18nTechnicalData` ON (`i18nTechnicalData`.`technical_data_id`=`technicalDatas`.`id`) AND (i18nTechnicalData.lang_id='es')  WHERE (`technicalDatas`.`product_id`=1)

Any idea? Thx

#8388 report it
fredpeaks at 2012/05/30 01:41pm
Re: admin view...

@sirfaber: Hi, I'm sorry but I can't help you with so little information. Maybe you can send me a private message on the forum (here) with some code and the complete error so I can see what's going on ;)

Leave a comment

Please to leave your comment.

Create extension