CakePHP – how to actually use i18n

Our goal here is to make the GroupModel translatable using the build in TranslateBehavior and a separate i18n table. In my example the GroupModel stores the names of what groups merchants belong to. For instance one merchant belong to the ‘Shoes’-group and another to the ‘Entertainment’-group. The group model only stores a name field apart from id, created and modified data.

To reach our goal there are a number of steps we have to complete.

  1. We need a GroupModel that uses the translate behavior
  2. We need a groups table in the database
  3. A custom GroupTranslationModel have to be created
  4. We need a group_translations table in the database
  5. Group::add complete with a view
  6. Group::edit complete with a view

First of all lets clear out some misconceptions.
I initially thought that data had to be redundant and stored in both the GroupModel’s table AND the i18n table, that’s wrong and I’ll show you why later.

There also seemed that no one had solved the problem of storing several translations with one form. It’s possible, it’s convenient and not very hard once you understand the concept.

Lets start out by looking at the Group Model. So far nothing strange, it’s all in line with the chapter about Translation behavior in the Cookbook.

//File: app/Model/Group.php
<?php
App::uses('AppModel', 'Model');

class Group extends AppModel {

	public $name = 'Group';
	public $displayField = 'name';

	/* Tell our model that we always want the translations as well when we do a Model::find
	 * We have defined a separate small GroupTranslation model for this
	 * As well as a separate db-table to store the translations in.
	 */
	public $actsAs = array(
		'Translate'	=>	array(
			'name'	=>	'nameTranslation'
			)
	);
	public $translateModel = 'GroupTranslation'; //Use a non-default Model for translations
	public $translateTable = 'group_translations'; //Use a non-standard table for the category translations
}

We told the GroupModel to use another model for Translations. We don’t have to do so but I find it a bit messy to only have one I18n table storing translations for each and every model. The database-designer in me stops me from doing so.

So lets look at the very short and sweet GroupTranslationModel. It’s still perfectly in line with the cookbook.

class GroupTranslation extends AppModel {
    public $displayField = 'field'; // important
}

Here comes the first missing part of the CakePHP 2.0 puzzle. How should the table we like to have translated look. How should the groups table look here?

CREATE TABLE IF NOT EXISTS `groups` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=13 ;

There is no name field here. All that is handled by the translation model. We will still be able to use the name field as if it was here when we use Model::find and the likes later but note that we don’t need to store the data we want translated in this table. A gotcha is that the table actually need to store something besides the id to work, created and modified fields will do just fine.

I then used the console to bake me a i18n table which I renamed to group_translations. This is also in line with the documentation. If you haven’t used the console to bake stuff with cake I urge you to try it out. It simplifies a lot of stuff especially when it comes to i18n and l10n.

CREATE TABLE IF NOT EXISTS `group_translations` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `locale` varchar(6) COLLATE utf8_swedish_ci NOT NULL,
  `model` varchar(255) COLLATE utf8_swedish_ci NOT NULL,
  `foreign_key` int(10) NOT NULL,
  `field` varchar(255) COLLATE utf8_swedish_ci NOT NULL,
  `content` text COLLATE utf8_swedish_ci,
  PRIMARY KEY (`id`),
  KEY `locale` (`locale`),
  KEY `model` (`model`),
  KEY `row_id` (`foreign_key`),
  KEY `field` (`field`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=61 ;

The next step would be to create a GroupsController::add action. I baked it with the console and haven’t changed a line here. It handles everything exceptionally nice from the start.

public function add() {
		if ($this->request->is('post')) {
			$this->Group->create();
			
			if ($this->Group->save($this->request->data)) {
				$this->Session->setFlash(__('The group has been saved'));
				$this->redirect(array('action' => 'index'));
			} else {
				$this->Session->setFlash(__('The group could not be saved. Please, try again.'));
			}
		}
	}

Next up is the view for this action. Even though we haven’t defined a name field in our GroupModel, cake understands what to do with the data that is passed to it in the names array. The language codes appended on the end follows ISO 639-2 which you can read more about in this chapter in the cookbook.

<?php echo $this->Form->create('Group');?>
	<fieldset>
		<legend><?php echo __('Add Group'); ?></legend>
	<?php
		echo $this->Form->input('Group.name.eng');
		echo $this->Form->input('Group.name.swe');
		echo $this->Form->input('Group.name.fin');
		echo $this->Form->input('Group.name.nob');
		echo $this->Form->input('Group.name.dan');
	?>
	</fieldset>
<?php echo $this->Form->end(__('Submit'));?>
</div>

Ok that didn’t look to hard did it. If we want to nicen it up we could of course keep an array of supported languages somewhere and dynamically generate all fields needed. Here comes the trickiest part so hold on to your hat, the GroupController::edit action and it’s view. But lets start with the view, it’s not that scary in it self.

The only difference here is that we need the id really. Nothing out of the ordinary.

<?php echo $this->Form->create('Group');?>
	<fieldset>
		<legend><?php echo __('Admin Edit Group'); ?></legend>
	<?php
		echo $this->Form->input('id');
		echo $this->Form->input('Group.name.eng');
		echo $this->Form->input('Group.name.swe');
		echo $this->Form->input('Group.name.fin');
		echo $this->Form->input('Group.name.nob');
		echo $this->Form->input('Group.name.dan');
	?>
	</fieldset>
<?php echo $this->Form->end(__('Submit'));?>

To populate the fields correctly is another story. Lets dig into the GroupController::edit action.

	public function edit($id = null) {
		$this->Group->id = $id;
		if (!$this->Group->exists()) {
			throw new NotFoundException(__('Invalid group'));
		}
		if ($this->request->is('post') || $this->request->is('put')) {

			//We use saveMany here for it to work.			
			if ($this->Group->saveMany($this->request->data)) {
				$this->Session->setFlash(__('The group has been saved'));
				$this->redirect(array('action' => 'index'));
			} else {
				$this->Session->setFlash(__('The group could not be saved. Please, try again.'));
			}
		} else {
			//To fetch the groups we first of all just read them as usual.
			$groups = $this->Group->read(null, $id);
			/* But the resulting array contains all translations under
			 * the index 'nameTranslation' that we set earlier in the GroupModel.
			 * Set::combine conveniently transforms this to a format we are used to and
			 * that populates the edit form automatically.
			 */
			$result = Set::combine($groups,'nameTranslation.{n}.locale','nameTranslation.{n}.content');
			$this->request->data = $groups;
			$this->request->data['Group']['name'] = $result;
		}
	}

That’s it really. It took me a few hours to figure out and there are still some quirks to overcome when working with translated models that have associations with other translated models, but I might write another blog post about that if this one is read by the masses.

But please please .. drop a comment, skype me, tweet to me, email me if anything is unclear. I’ll help you out and update the post so that others can read about it as well.

Cheers and happy baking :)
Questions? I’m sure you have a few, so please post a comment and I’ll be quick about updating this post.

16 thoughts on “CakePHP – how to actually use i18n

  1. Hi Kristoffer,

    Great tutorial, a really good start point for translating my pages but there’s a small problem. In the edit action, when submitting data I get a wrong query -> UPDATE `generic_app`.`pages` SET `id` = 5, `title` = Array, `body` = Array, `slug` = ‘tttt’, `parent` = NULL, `modified` = ’2012-06-11 21:34:10′ WHERE `generic_app`.`pages`.`id` = ’5′
    However when using save() instead of saveMany() it works like a charm.

    • Good that you got it working. The difference might come from some association, tricky to say right away.

      Great that you had some use for the tutorial at least. :)

  2. Can’t thank you enough for this. I find Cake capable but the documentation lets it down – it’s like it tells 90% of the story but self discovery it takes for the creation and necessity of multi-creation of i18n fields is yet another example of the missing 10%.

    Thanks hugely for this, you’ve saved me countless hours getting it doing exactly what you’ve got here!

  3. Hey Kristoffer, thanks for sharing the knowledge !

    I just wanted to ask you, what happenend when you call
    $this->Group->find(‘list’);
    You probably will get a database error because you are specifing a ‘name’ as a $displayField on the model, but this field doesnt exists on the groups table
    Have tought on this matter ?

    • Since Group uses the Translate behavior I think it should work anyway. The ‘name’ field is present in the translations so Group should grab it from there.

      But I can’t say that I have tried doing a list-find specifically. If it doesn’t work the do a find all and extract the list values from there.

  4. all things working as you said except when you edit file because it didn’t retrieve the data of the fields .
    it retrieve only one letter in the field.
    in my case
    i wrote
    echo $this->Form->input(‘Test.title.ara’, array(‘label’ => ‘Arabic Title’,’value’=>$this->data['titleTranslation']['1']['content'] ));
    Have you any solutions for this ?

    • It sounds like your applying a numeric index to a string index somewhere.

      That’s why I’m using that Set::combine (or Hash::combine as you should use in newer versions of cake), to rebuild my data a bit. But setting the value should also be fine.

      Does $this->data look fine when you print it out with pr?

  5. I’ve been testing around for a couple of hours with no success. Now I worked through this tutorial and every thing is fine. Thanks a lot!

  6. Thanks for the tutorial, especially for the edit action tip!

    One thing I had to do to get my add action to work, however, was to use saveMany() instead of just save(). It’s probably because in other parts of the application I have a language switcher which saves the current language setting, and I think save() just used that setting to only save the current language. With saveMany(), all the languages get saved properly.

    Seems like with your help, I was able to finalize a complete solution – starting with route /:language prefixes, automatic setting of the chosen language in url(), language switcher, adding and editing using a i18n table, and using .po files. I might as well write my own blog post about the process, since it was quite a process to get all the puzzles together :-). would you mind me mentioning some of your solutions, with credits given?

    Thanks again!

    • Ooops – never mind, my bad. Earlier when save() didn’t work, I didn’t have the actsAs declaration formatted properly (‘name’ => ‘nameTranslation’, I only used ‘name’). Now I declared the translations properly and save() works as it should!

  7. How about validating the inputs in different languages? What i mean is these fields:

    echo $this->Form->input(‘Group.name.eng’);
    echo $this->Form->input(‘Group.name.swe’);
    echo $this->Form->input(‘Group.name.fin’);
    echo $this->Form->input(‘Group.name.nob’);
    echo $this->Form->input(‘Group.name.dan’);

    Also when you add the “.lang” after the “Group.name” it will not validate based on the validation rules set in the model. One of my validation rules is the field cannot be empty.

    Any ideas how to properly validate all fields?

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>