A Symfony 8 bundle for multilingual applications. Provides two complementary i18n systems:
- Entity localization — multi-locale Doctrine entity pairs with automatic ORM relationship mapping at runtime.
- Form field localization — database-backed translation keys for individual form fields with XLIFF export.
- Automatic ORM mapping via
TranslateSubscriber: mapsoneToMany/manyToOneassociations at runtime — no manual Doctrine mapping required. - Locale fallback chain in
translate(): requested locale → language fallback (en_US→en) → kernel default locale. TranslatableProxyTraitfor transparent property delegation:$post->titlereads from the current translation without extra calls.- Form field localization via
localization: trueonTextType,TextareaType, andWysiwygType— stores opaque UUID-based keys in the entity, displays human-readable values in the form. LocalizationLoaderChain— tagged, prioritized loader chain for resolving existing translation values; extend with custom loaders.ExportTranslationCommand(translation:export) — writes un-exportedTranslationrecords to+intl-icu.{locale}.xlifffiles grouped by domain, then marks them as exported.- CMS integration (optional, requires
chamber-orchestra/cms-bundle) —TranslationsTypecollection pre-populated per locale, rendered as Bootstrap nav tabs.
- PHP ^8.5
- Symfony 8.0 (framework-bundle, form, translation, uid, console, http-foundation)
- Doctrine ORM ^3.0 + DoctrineBundle ^3.0
Optional:
chamber-orchestra/doctrine-clock-bundle— required if translatable entities useTimestampCreateTraitchamber-orchestra/cms-bundle— CMS form integration (TranslationsType,AbstractTranslatableDto)
composer require chamber-orchestra/translation-bundleEnable the bundle in config/bundles.php:
return [
// ...
ChamberOrchestra\TranslationBundle\ChamberOrchestraTranslationBundle::class => ['all' => true],
];Define a translatable/translation entity pair. The TranslateSubscriber maps their Doctrine relationship automatically.
Translatable entity — implements TranslatableInterface + uses TranslatableTrait:
use ChamberOrchestra\TranslationBundle\Contracts\Entity\TranslatableInterface;
use ChamberOrchestra\TranslationBundle\Entity\TranslatableTrait;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Post implements TranslatableInterface
{
use TranslatableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
// No manual Doctrine mapping needed for $translations —
// TranslateSubscriber wires it automatically at loadClassMetadata.
}Translation entity — implements TranslationInterface + uses TranslationTrait.
The class name must be the translatable class name suffixed with Translation:
use ChamberOrchestra\TranslationBundle\Contracts\Entity\TranslationInterface;
use ChamberOrchestra\TranslationBundle\Entity\TranslationTrait;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'post_translation')]
class PostTranslation implements TranslationInterface
{
use TranslationTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
#[ORM\Column]
public string $title = '';
// $locale and $translatable are provided by TranslationTrait.
// The ManyToOne → Post association is mapped automatically.
public function __construct(Post $post, string $locale, string $title)
{
$this->translatable = $post;
$this->locale = $locale;
$this->title = $title;
}
public function getId(): int { return $this->id; }
}Reading translations:
// Current request locale (injected by TranslateSubscriber on postLoad):
$post->translate()->title;
// Explicit locale:
$post->translate('ru')->title;
// Fallback chain: fr_CA → fr → kernel default locale:
$post->translate('fr_CA')->title;Template shorthand with TranslatableProxyTrait — delegates $post->title to $post->translate()->title:
use ChamberOrchestra\TranslationBundle\Entity\TranslatableProxyTrait;
class Post implements TranslatableInterface
{
use TranslatableTrait;
use TranslatableProxyTrait; // enables $post->title in Twig
// ...
}{# Both are equivalent after using TranslatableProxyTrait: #}
{{ post.translate().title }}
{{ post.title }}What TranslateSubscriber does automatically:
| Trigger | Action |
|---|---|
loadClassMetadata on Post |
Maps oneToMany translations collection indexed by locale, cascade persist/remove |
loadClassMetadata on PostTranslation |
Maps manyToOne translatable with CASCADE DELETE; adds unique constraint (translatable_id, locale) |
postLoad |
Injects currentLocale and defaultLocale from RequestStack / kernel default |
prePersist |
Injects currentLocale and defaultLocale on new entities |
Add localization: true to any TextType, TextareaType, or WysiwygType field. The entity stores an opaque key; the form shows the human-readable value.
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
class ServiceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'localization' => true,
'localization_domain' => 'messages', // default: 'messages'
'localization_context' => ['ui' => 'service_name'], // optional, passed to TranslationEvent
])
->add('description', TextareaType::class, [
'localization' => true,
]);
}
}What happens on submit:
TranslatableTypeExtensiondispatches aTranslationEvent(key, value, context).- Your listener persists the
Translationentity:
use ChamberOrchestra\TranslationBundle\Events\TranslationEvent;
use ChamberOrchestra\TranslationBundle\Entity\Translation;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final class TranslationPersistListener
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function __invoke(TranslationEvent $event): void
{
$translation = Translation::create($event->key, $event->value, $event->context);
$this->em->persist($translation);
}
}- The entity stores the key (
messages@name.{uuid}), not the human-readable text. Symfony's translator resolves it at render time once exported.
Export stored translations to XLIFF:
php bin/console translation:exportWrites {domain}+intl-icu.{locale}.xliff files to %translator.default_path%, marks records as exported, and dispatches TranslationExportedEvent.
Translation key format:
{domain}@[prefix.]uuid
use ChamberOrchestra\TranslationBundle\Utils\TranslationHelper;
use Symfony\Component\Uid\Uuid;
$uuid = Uuid::v7();
$key = TranslationHelper::getLocalizationKey('messages', $uuid, 'service');
// → "messages@service.{uuid}"
TranslationHelper::getDomain($key); // "messages"
TranslationHelper::getId($key); // Uuid instance
TranslationHelper::getMessage($key); // "service.{uuid}"Requires chamber-orchestra/cms-bundle. Renders per-locale tabs in CMS edit forms:
use ChamberOrchestra\TranslationBundle\Cms\Form\Type\TranslatableTypeTrait;
class PostType extends AbstractType
{
use TranslatableTypeTrait; // adds $builder->add('translations', TranslationsType::class, ...)
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addTranslationsField($builder, PostTranslationType::class);
}
}Configure available locales in config/services.yaml:
parameters:
chamber_orchestra.translation_locales: [ru, en, de]Implement LocalizationLoaderInterface and tag the service with chamber_orchestra.localization_loader. The LocalizationLoaderChain resolves existing translations by priority:
use ChamberOrchestra\TranslationBundle\Form\Loader\LocalizationLoaderInterface;
final class DatabaseLocalizationLoader implements LocalizationLoaderInterface
{
public function load(string $key): ?string
{
// Return the human-readable value for this key, or null to pass through.
}
}# config/services.yaml
App\Localization\DatabaseLocalizationLoader:
tags:
- { name: chamber_orchestra.localization_loader, priority: 10 }Integration tests require a PostgreSQL database. Set DATABASE_URL or use the default from phpunit.xml.dist:
composer install
DATABASE_URL="postgresql://user:pass@127.0.0.1:5432/mydb?serverVersion=17&charset=utf8" \
./vendor/bin/phpunitRun only unit tests (no database required):
./vendor/bin/phpunit --testsuite UnitMIT