There are people around here claiming that the Symfony form framework is a gift from the gods. One might say that this statement is a bit exaggerated, but it’s true that the release of the 1.3/1.4 version of the Symfony brought many improvements, and yes, the form framework can save you a lot of time.
In one of my current project, I had to developp some basic CMS-like features. In a back-office, an admin should be able to writes different kind of articles (news, interviews…). Hey ! That’s a job for inheritance ! But wait, there’s more. Articles can be translated in several languages. Easy, with the Doctrine I18n behavior.
One last requirement : article translations are optional. One might publish an article in english, an other in french, and a third in both languages. This is a little more tricky, so let’s see how to do this.
Some references
Before we start, here are the wonderful ressources I read to get my way out :
http://prendreuncafe.com/blog/post/2009/11/29/Embedding-Relations-in-Forms-with-Symfony-1.3-and-Doctrine
http://symfonyguide.wordpress.com/2009/09/28/symfony-forms-saving-process/
http://vousavezchoisi.com/img/formsdiagram.png
http://www.symfony-project.org/more-with-symfony/1_4/en/06-Advanced-Forms#chapter_06_ignoring_embedded_forms
http://www.ze-technology.com/2009/06/05/tests-fonctionnels-avec-symfony-gerer-lidentification/
Let’s start with the schema
Article:
actAs:
Timestampable: ~
I18n:
fields: [ title, body ]
actAs:
Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
columns:
title: { type: string(255), notnull: true }
body: { type: clob, notnull: true }
author: { type: string(255), notnull: false }
News:
inheritance:
extends: Article
type: concreteAs it’s friday, I will also give you some fixtures :
News:
n1:
author: 'Fantomas'
Translation:
fr:
title: 'OK, l''Ipad est sorti. Vous allez me foutre la paix maintentant ?'
body: |
Vous commencez serieusement à me gonfler avec vos articles et vos tweets sur
une techno inutile et bardée de DRM.
n2:
author: 'Garcin Fony'
Translation:
fr:
title: 'Sinon, à part ça, ça va ?'
body: |
Allez, pour me calmer, je vais me refaire un p'tit caféLet’s build the whole thing, and generate the admin in the same time.
php symfony doctrine:build --all --and-load php symfony generate:app backend php symfony doctrine:generate-admin backend News
Check the module admin we’ve just build. Click on the first « edit » button. Wait a minute ? The I18n fields are just missing, how are we gonna edit our articles ? If you’re not familiar with the I18n behavior, you must know that the content is split into two tables. The new table, « article_translation », holds all the I18n fields.
mysql> SELECT * FROM news; +----+-------------+---------------------+---------------------+ | id | author | created_at | updated_at | +----+-------------+---------------------+---------------------+ | 1 | Fantomas | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | | 2 | Garcin Fony | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | +----+-------------+---------------------+---------------------+ mysql> SELECT id, lang, title FROM news_translation; +----+------+------------------------------------------------------------------+ | id | lang | title | +----+------+------------------------------------------------------------------+ | 1 | fr | OK, l'Ipad est sorti. Vous allez me foutre la paix maintentant ? | | 2 | fr | Sinon, à part ça, ça va ? | +----+------+------------------------------------------------------------------+
So, to be able to edit our translations, you have to embed the I18n forms, using the embedI18 function. Let’s do that.
// lib/form/doctrine/NewsForm.class.php class NewsForm extends BaseNewsForm { /** * @see ArticleForm */ public function configure() { parent::configure(); $this->embedI18n(array('fr', 'en')); } }
Reload the edit page. Now you can edit your news in french and english. Pretty cool, isn’t it ?
Let’s refactor this mess
Since now, it’s been pretty easy. However, the code we’ve written is bad. It’s bad, for the following reasons :
- Each time we will add a new article type (e.g. interview, etc.), we will have to update it’s configure method ;
- Each time we will change the available languages (to add a new one, or disable an existing one), we will have to edit every forms ;
Fortunately, since Symfony 1.3, the form inheritance follow the model structure. Notice that NewsForm extends BaseNewsForm, which extends ArticleForm.
Let’s refactor our code :
# config/app.yml
all:
cultures:
enabled:
fr: Français
en: English// lib/form/doctrine/NewsForm.class.php // Revert the changes we added there class NewsForm extends BaseNewsForm { /** * @see ArticleForm */ public function configure() { parent::configure(); } } // lib/form/doctrine/ArticleForm.class.php class ArticleForm extends BaseArticleForm { /** * Available languages * * @var array $languages **/ protected $langages; public function configure() { $this->languages = sfConfig::get('app_cultures_enabled'); $langs = array_keys($this->languages); $this->embedI18n($langs); foreach($this->languages as $lang => $label) { $this->widgetSchema[$lang]->setLabel($label); } } }
Reload the edit form. Now, no matter how many articles sub-types you add, you still can configure available languages from one place.
Translation edition
Let’s try our brand new admin module by editing a news. If you used the fixtures I provided, you should have a french article, with no english translation. Try to update the french version, and click on the ‘Save’ button.
This is a miserable failure. We can’t save the form, because the english fields are required. We’re stuck, we have to fill every translation for an article before we can save it. Wouldn’t it be nice if we could make a translation form optional ? Let’s add a requirement : if every fields in a translation form are empty, it just should be ignored.
To achieve this goal, we will update the doBind method. Here’s the new ArticleForm class.
// lib/form/doctrine/ArticleForm.class.php class ArticleForm extends BaseArticleForm { /** * Available languages * * @var array $languages **/ protected $langages; public function configure() { $this->languages = sfConfig::get('app_cultures_enabled'); $langs = array_keys($this->languages); $this->embedI18n($langs); foreach($this->languages as $lang => $label) { $this->widgetSchema[$lang]->setLabel($label); } } /** * Cleans and binds values to the current form * * Ignore i18n forms when all their fields are empty * * @see sfForm::doBind **/ protected function doBind(array $values) { foreach($this->languages as $lang => $label) { if($this->embeddedI18nFormIsEmpty($values[$lang])) { unset( $values[$lang], $this[$lang] ); } } parent::doBind($values); } /** * Check if every fields, except for id and lang, are empty **/ protected function embeddedI18nFormIsEmpty(array $values) { foreach($values as $key => $value) { if(in_array($key, array('id', 'lang'))) continue; if('' !== trim($value)) { return false; } } return true; } }
In the overidden doBind method, we check, for every I18n form, if some values has been submited. If not, we just unset the corresponding fields.
Reload the edit form, update the french translation, leave the english field empty, and click on ‘Save’. Hurray, the forms is saved correctly.
However, you should keep reading, because you’re gonna have a surprise. Let’s look into the database.
mysql> SELECT id, lang, slug FROM news_translation; +----+------+--------------------------------------------------------------+ | id | lang | slug | +----+------+--------------------------------------------------------------+ | 1 | en | | | 1 | fr | ok-l-ipad-est-sorti-vous-allez-me-foutre-la-paix-maintentant | | 2 | fr | sinon-a-part-ca-ca-va | +----+------+--------------------------------------------------------------
Something went wrong ! Somewhere in the process, Symfony created an empty translation object for our article. Notice the void « slug » field ? If you try to edit another news, you will receive a duplicate content error. WTF ?
I must admit that I don’t fully understand why this happens (sometimes I think I do, and a few minutes later, I realize I don’t). However, here’s the solution to overcome this problem.
// lib/form/doctrine/ArticleForm.class.php // Add this at the beginnig of the class: /** * I18n ignored forms **/ protected $I18nFormsIgnored = array(); // update the doBind method: /** * Unset i18n forms values when every field is empty **/ protected function doBind(array $values) { foreach($this->languages as $lang => $label) { if($this->embeddedI18nFormEmpty($values[$lang])) { $this->I18nFormsIgnored[] = $lang; unset( $values[$lang], $this[$lang] ); } } parent::doBind($values); } // And override the doUpdateObject method: /** * Updates the values of the object with the cleaned up values. * * @param array $values An array of values * * @see sfFormDoctrine::doUpdateObject() */ protected function doUpdateObject($values) { parent::doUpdateObject($values); foreach($this->I18nFormsIgnored as $lang) { unset($this->object->Translation[$lang]); unset($values[$lang]); } }
Delete the useless line in the mysql database, so we can start on a clean base. Once more, reload the edit form, leave the english translation empty, save the form, et voilà ! The form is saved, the translation is updated, and no empty line is added in the table.
Adding and deleting a translation
Just to be sure, add some data in the english translation title, and try to save the form. You should get a « body is required » error, which is the expected behavior. Try to fill correctly the english translation, and save again. Check that the english translation is correctly added in the database. Erase all french fields, save one more time, and check that the french translation is definitly removed from the DB (no dummy entry).
Well, it seems that our form is pretty functional. One last thing : there is still a « slug » field in each translation form, that you might want to unset. Easy, just edit the ArticleTranslationForm.class.php file :
// lib/form/doctrine/ArticleTranslationForm.class.php class ArticleTranslationForm extends BaseArticleTranslationForm { public function configure() { unset($this['slug']); } }
Edit a news. If you’re using Symfony 1.{3,4}.1, you’ll see that the damn slug field is still there. It’s a Symfony bug. Indeed, the translations forms don’t follow the inheritance schema. Notice that BaseNewsTranslationForm extends BaseFormDoctrine instead of ArticleNewsTranslation.
You’ll have to manually change that, however, this will be overidden each time you rebuild your forms.
// lib/form/doctrine/base/BaseNewsTranslationForm.class.php // Replace abstract class BaseNewsTranslationForm extends BaseFormDoctrine // with abstract class BaseNewsTranslationForm extends ArticleTranslationForm // lib/form/doctrine/NewsTranslationForm.class.php // Edit the configure method class NewsTranslationForm extends BaseNewsTranslationForm { public function configure() { parent::configure(); } }
Now, the slug field should disapear. At the present moment, the bug is still open.
Let’s test everything
As it’s friday, I feel generous, so as a bonus, I will give you the functional tests to cover our magical form. Enjoy.
// lib/sfBackendTestFunctional.class.php /** * This class is used to run functional tests in a secured backend * * Takes care of the login action, and the fixtures loading */ class sfBackendTestFunctional extends sfTestFunctional { public function __construct($browser, $lime = null, $testers = array()) { parent::__construct($browser, $lime, $testers); } /** * Perform user authentication * * @param array of String $user_data * @return sfGuardTestFunctional $this */ public function signin($user_data) { return $this->info(sprintf('Login as "%s"', $user_data['username']))-> get('/login')-> click('sign in', array('signin' => $user_data), array('_with_csrf' => true))-> with('form')->begin()-> hasErrors(false)-> end()-> with('user')->begin()-> isAuthenticated(true)-> end()-> with('request')->begin()-> isParameter('module', 'sfGuardAuth')-> isParameter('action', 'signin')-> end()-> with('response')->begin()-> isRedirected()-> followRedirect()-> end() ; } /** * Load project fixtures **/ public function loadData() { Doctrine::loadData(sfConfig::get('sf_data_dir').'/fixtures'); return $this; } }
// test/functional/backend/newsActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfBackendTestFunctional(new sfBrowser()); $browser->loadData(); $browser->setTester('doctrine', 'sfTesterDoctrine'); $languages = sfConfig::get('app_cultures_enabled'); $news = Doctrine::getTable('News')-> createQuery('n')-> leftJoin('n.Translation t')-> andWhere('t.lang = ?', 'fr')-> orderBy('n.id', 'asc')-> fetchOne(); $editUrl = sprintf('/news/%s/edit', $news->getId()); $browser-> // Uncomment this if your backend is protected by a login //signin(array( // 'username' => 'admin', // 'password' => 'admin' //)))-> get('/news')-> with('response')->begin()-> isStatusCode(200)-> end()-> info('1 - News list')-> with('response')->begin()-> checkElement('h1', '/News List/')-> checkElement('body', '/Fantomas/')-> end()-> info('2 - News edit')-> info(' 2.1 - I18n forms for all languages are embedded')-> get($editUrl)-> with('response')->begin()-> isStatusCode(200)-> checkElement('input[name$="[title]"]', count($languages))-> end()-> info(' 2.2 - I18n forms are validated')-> get($editUrl)-> click('Save', array('news' => array( 'en' => array( 'title' => 'toto', 'body' => '' )) ), array('_with_csrf' => true) )-> with('form')->begin()-> hasErrors(1)-> isError('en[body]', 'required')-> end()-> info(' 2.3 - Empty forms are validated')-> get($editUrl)-> click('Save', array('news' => array( 'en' => array( 'title' => '', 'body' => '' )) ), array('_with_csrf' => true) )-> with('form')->begin()-> hasErrors(0)-> end()-> info(' 2.4 - Translations are updated')-> get($editUrl)-> click('Save', array('news' => array( 'fr' => array( 'title' => 'toto tutu tata', 'body' => 'riri fifi loulou' )) ), array('_with_csrf' => true) )-> with('form')->begin()-> hasErrors(0)-> end()-> with('doctrine')->begin()-> check('NewsTranslation', array( 'id' => $news->getId(), 'lang' => 'fr', 'title' => 'toto tutu tata' ))-> info(' 2.5 - No empty translation is created')-> check('NewsTranslation', array( 'id' => $news->getId(), 'lang' => 'en', ), false)-> end()-> info(' 2.6 - New translations can be added')-> get($editUrl)-> click('Save', array('news' => array( 'en' => array( 'title' => 'toto tutu tata', 'body' => 'riri fifi loulou' )) ), array('_with_csrf' => true) )-> with('form')->begin()-> hasErrors(0)-> end()-> with('doctrine')->begin()-> check('NewsTranslation', array( 'id' => $news->getId(), 'lang' => 'en', 'title' => 'toto tutu tata' ))-> end()-> info(' 2.7 - Existing translations can be deleted')-> get($editUrl)-> click('Save', array('news' => array( 'fr' => array( 'title' => '', 'body' => '' )) ), array('_with_csrf' => true) )-> with('form')->begin()-> hasErrors(0)-> end()-> with('doctrine')->begin()-> check('NewsTranslation', array( 'id' => $news->getId(), 'lang' => 'fr', ), false)-> end() ;
Here we are. Hope you find this helpful. I thought some kind of requirement would be pretty common, however, I didn’t find any directly related howto’s. If you can think of any other way to achieve this, please, let me know.

9 Commentaires
Very nice article. I overcame similar situations in the past, and solved them using the same kind of approach. But it still frustrates me a bit, the i18n features of the forms framework are still too much unpolished in my opinion, a lot of beginners are just giving up as soon as they encounter the mentionned native limitations…
Super article, tu m’a enlevé une baramine du pied !
Tout à fait d’accord avec NiKo, la couche I18n n’est pas mature notamment sur les héritages, il manque de la doc !
Bonne continuation
Realy BIG thanks
small bug : in second doBind method embeddedI18nFormEmpty
Bonjour,
Après avoir suivi ce tutoriel j’ai tout de même encore rencontré des problèmes avec l’intégrité référentiel que j’ai résolu.
J’obtenais ceci : « SQLSTATE23000 : Integrity constraint violation : 1452 (…) » à l’édition d’un formulaire. En fait Symfony mettait à jour par défaut le champ id ainsi que le champ langue au moment de l’enregistrement.
Un simple :
[code]
unset($this['slug'],$this['id'],$this['lang']) ;
[/code]
Dans la méthode configure() du formulaire xxxTranslationForm a permit de résoudre le problème.
Comme j’ai plusieurs formulaire i18n sur mon application j’ai également factorisé les méthodes décrites plus haut dans la classe BaseFormDoctrine en ajoutant la condition suivante :
protected function doBind(array $values)
{
if($this->isI18n()){
foreach($this->i18n_langs as $lang)
{
//…
}
}
Ainsi que :
protected function doUpdateObject($values)
{
parent::doUpdateObject($values) ;
if($this->isI18n()){
foreach($this->I18nFormsIgnored as $lang)
{
(oups on peut pas éditer et j’ai soumis le commentaire par erreur)
J’avais plus ou moins terminé. Simplement j’ajouterai que j’ai une autre approche pour déterminer les langues saisissables puisque je les stocke dans une table lang. Il suffit donc de requêter la table pour alimenter la méthode embedI18n().
En tout cas merci pour ce tutoriel, tu m’as donné un sacré coup de pouce !
This works great if you want to add a new translation and fill in all the necessary fields. A problem arises if you miss a required field for one language and leave another language completely blank. You are correctly warned that the first language requires the missing field, but the other language you left blank has now been removed from the page. When you do fill in that required field :
Notice : Undefined index : de in C:\Sites\haysoluciones\lib\form\doctrine\ContentForm.class.php on line 66 Catchable fatal error : Argument 1 passed to ContentForm::embeddedI18nFormIsEmpty() must be an array, null given
Shouldn’t the empty language form remain on the page so it can be recognised as having no values ?
I got the same problem as Thomas.
I have a form with french required fields and english not required.
So when a french field was missing it crashes because it does not found english embeded form anymore.
I do not unset anything in doBind(), I put « unset($values[$lang], $this[$lang]) ; » in doUpdateObject().
Hi.
Thanks for your post, i was desperate trying to make symfony ignore empty forms. I was looking at Advent calendar examples, where Photo forms were embedded, and not I18n. So they were overriding saveEmbeddedForms and adding some custom validatorSchema. But I’ve encountered some nasty bugs trying to apply this for I18n forms.
I want to ask you a couple of things. When the form is not valid – erorrs show up, and unset forms dont present at this page. It’s logical, but i would like them not to disappear. I’ve tried to remove some unsets, but there a problem « Unexpected fields ‘id’ and ‘lang’ ». Do you know why do this error happens ? I did got the same with advent calendar methods.
I hope you wont mind, that i translate this post into Russian and post on my blog with the link to you as this info is good to know.
Hi, I tried this and also get the « id » and « lang » unexpected fields, if I unset() them on ObjectTranslationForm it fails with the « Integrity constraint violation » error, looking at the query it tries to insert instead of update. Anyone else seems this ? any fixes ?
thanks !
One Trackback
[...] Календаря Адвента, и не добившись успеха, я нашел пост [Optional translation form for I18n objects with Symfony and Doctrine], который мне помог. Я считаю, что полезным будет [...]