diff --git a/README.md b/README.md index 2f7a53e..c020e7c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -![webfactory Logo](https://www.webfactory.de/bundles/webfactorytwiglayout/img/logo.png) WebfactoryPolyglotBundle -======== +# ![webfactory Logo](https://www.webfactory.de/bundles/webfactorytwiglayout/img/logo.png) WebfactoryPolyglotBundle [![Build Status](https://travis-ci.org/webfactory/polyglot-bundle.svg?branch=master)](https://travis-ci.org/webfactory/polyglot-bundle) [![Coverage Status](https://coveralls.io/repos/webfactory/polyglot-bundle/badge.png?branch=master)](https://coveralls.io/r/webfactory/polyglot-bundle?branch=master) @@ -20,187 +19,188 @@ German", where the linked URL has a locale specific slug. If you're fine with the [known limitations](#known-limitations), read on! -Installation ------------- +## Installation + +Just like any other Symfony bundle, and no additional configuration is required (wheeho!). + +## Underlying Data Model and How It Works + +The assumption is that you already have a "main" Doctrine entity class with some fields that you now need to make locale-specific. + +To do so, we'll add a new _translation entity class_ that contains these fields plus a field for the locale. The main entity and the translation entity class will be connected with a `OneToMany` association. + +So, for one single _main_ entity instance, there are zero to many _translation entity_ instances – one for every locale that you have a translation for. + +This approach reflects our experience that almost always the relevant content (field values) are maintained for a "primary" locale. This is the "authoritative" version of your content/data. Then, this content is translated for one or several "secondary" locales. The _translation entity class_ is concerned with holding this translated data only. + +Now, this bundle sets up a Doctrine event listener (`\Webfactory\Bundle\PolyglotBundle\Doctrine\PolyglotListener`) to +be notified whenever a Doctrine entity is hydrated, that is, re-created as a PHP object based on database values. + +This listener finds all fields in the entity that are marked as being locale-specific and replaces their value with +a value holder object. These value holders are instances of `\Webfactory\Bundle\PolyglotBundle\TranslatableInterface`. +To learn more about the Value Holder pattern, see [Lazy Load in PoEAA](https://martinfowler.com/eaaCatalog/lazyLoad.html). + +You can then use this interface's `translate()` method to obtain the field value for a locale of your choice: +The value holder will take care of returning either the original value present in your _main_ entity or finding the +right _translation_ entity instance (for the matching locale) and take the field value +from there, depending on whether you requested the _primary_ or one of the _additional_ locales. If no matching translation is found, the primary +locale's data will be used. + +While this approach should work for any type of data, including objects, in your locale-dependent fields, it works particularly +well for strings: The value holder features a `__toString()` method that will return the value for the currently +active locale whenever the value holder object is used in a string context. + +Yet, it is worth noting that you're now dealing with the value holders in places where you previously had "your" +data or objects. They are *not* "almost" transparent proxies as those used by Doctrine because they do not +provide the same interface as the original values. Only for strings, the difference is sufficiently small. + +The good news for Twig users is that as of Twig 1.33, `__toString()` support in Twig is good enough so that you need + not care about the distinction of strings and translation value holders. So, Twig constructs like `{{ someObject.field }}` or + `{% if someObject.field is not empty %}...` will work the same regardless of your `getField()` method returns a string + value or the translation value holder. + +You think an example could help clearing up the confusion? Read on! + +## Usage Example -Simply add the following to your composer.json (see http://getcomposer.org/): - - "require" : { - // ... - "webfactory/polyglot-bundle": "@stable" - } - -And enable the bundle in `app/AppKernel.php`: - - text; - } - - /** - * @param string - */ - public function setText() - { - return $this->text; - } + return $this->text; } - +} +``` And now we want to make the `text` translatable. ### Step 1) Update the Main Entity -1. Annotate the main entity with the primary locale (in this case, the language of the database field `document.text`) - with `Webfactory\Bundle\PolyglotBundle\Annotation\Locale`. +1. For the main entity class, add the `Webfactory\Bundle\PolyglotBundle\Annotation\Locale` annotation to indicate your + primary locale. That's the locale you have used for your fields so far. 2. Annotate all translatable fields with `Webfactory\Bundle\PolyglotBundle\Annotation\Translatable`. 3. Add the association for the upcoming translation, and annotate it's field with - `Webfactory\Bundle\PolyglotBundle\Annotation\TranslationCollection` and make sure it's initialized with an empty + `Webfactory\Bundle\PolyglotBundle\Annotation\TranslationCollection`. Also make sure it's initialized with an empty Doctrine collection. -4. You may want to change your type hint for the translated fields from string to string|TranslatableInterface and cast - to string in your getters (more on that in the [Magic Explained](#magic-explained) section). +4. You may want to change your type hint for the translated fields from string to `string|TranslatableInterface`. + +This will lead you to something like the following, with some code skipped for brevity: -... and you will get something like this: +```php +translations = new ArrayCollection(); - } - - /** - * @return string - */ - public function getText() - { - return (string) $this->text; - } + $this->translations = new ArrayCollection(); } + /** + * @return string + */ + public function getText() + { + return $this->text->translate(); + } +} +``` ### Step 2) Create the Translation Entity 1. Create a class for the translation entity. As for the name, we suggest suffixing your main entity's name with "Translation". It has to contain all translated properties with regular Doctrine annotations - (e.g. @ORM\Column(type="text")). + (e.g. `@ORM\Column(type="text")`). 2. If you choose to name your back reference `entity` as we did in the example, you may want to extend `\Webfactory\Bundle\PolyglotBundle\Entity\BaseTranslation` to save yourself the hassle of rewriting some meta data - and a getter. But extending not necessary! -3. The translation entity needs to have the Doctrine back reference to the original entity, in our example the `$entity` - property. + and a getter. But extending this class is not necessary! +3. To implement the one-to-many relationship, the translation entity needs to reference to the original entity. + In our example, this is the `$entity` field. -... and you will end up with something like this: +Your code should look like this: - getText()->translate('de_DE') +Of course, you could also change the method to something along the lines of +```php +text->translate($locale); + } +} +``` -Magic Explained ---------------- -This bundle does its magic by integrating the Doctrine listener -`\Webfactory\Bundle\PolyglotBundle\Doctrine\PolyglotListener` into the Symfony stack, which provides the request locale. +... which should be backwards-compatible as well, but allows client code to access the values for locales of their +choice. -The main idea of the listener is to hook into -[Doctrine's lifecycle events](http://doctrine-orm.readthedocs.org/en/latest/reference/events.html) to replace string -values in annotated fields with `\Webfactory\Bundle\PolyglotBundle\TranslatableInterface`s. Their implementations use -the request's locale to provide the matching translation in their __toString() method, besides offering the other -translations as well. +Your last option would be to leave the getter unchanged, return the value holder object and have your client code deal +deal with it. This is not a 100% backwards-compatible solution, but as stated above, chances are you might get away with +it when changing string-typed fields only. The benefit of this approach would be that you can still choose the +locale further down the line. +**Caveat:** Note some subtle changes in case you're trying this approach: +```php + $myDocument = ...; + $text = $myDocument->getText(); + + if ($text) { ... } // Never holds because the value holder is returned (even if it contains a "" translation value) + if ($text === 'someValue') { ... } // Strict type check prevents calling the __toString() method +``` -Known Limitations ------------------ +## Known Limitations It's not very comfortable to persist entities and their translations. One might think: it's just Doctrine, the translation entity is the owning side of a cascade={"persist"}-association, so I'll just persist the translation. But no - we seem to have broken Doctrine's lifecycle management here. As a workaround, you can persist both the main entity and it's translation entities. See the tests for further details. - -Planned Features / Wish List ----------------------------- +## Planned Features / Wish List For now, each entity has one fixed primary locale. We have encountered cases in which some records were only available in a language different from the primary locale. Therefore, we want to remove the primary locale annotation and store this information in an attribute. This would allow each record to have its own primary locale. +## Credits, Copyright and License -Credits, Copyright and License ------------------------------- Copyright 2012-2017 webfactory GmbH, Bonn. Code released under [the MIT license](LICENSE). -