liip/formtranslationchoice-bundle
LiipFormTranslationChoiceBundle adds a Symfony form choice type that translates option labels through the Symfony Translator, letting select, radio, and checkbox choices display localized text without manual label mapping.
This bundle is archived, Symfony2-era, and unmaintained (10 stars, 0 dependents). Treat it as reference material rather than a dependency: read it to understand the pattern, then decide whether to vendor it for a legacy app or reimplement ~60 lines of code in a modern Symfony app.
If you are working in the legacy app it was written for:
Install from the archived tag, not a floating constraint. There is no maintained branch and no Flex recipe:
"require": {
"liip/formtranslationchoice-bundle": "dev-master"
}
Register the bundle manually in AppKernel::registerBundles():
new Liip\TranslationChoiceBundle\LiipTranslationChoiceBundle(),
Look first at the two classes that matter — the form type and the constraint/validator pair. Everything else (service definitions, DI extension) is glue. Confirm the exact FQCNs and option names against the repo's src/ before copying snippets from any blog post; the API is small but the naming differs between the bundle name (formtranslationchoice) and the namespace (TranslationChoice).
First use case: a settings/admin form where the stored value is a stable key and the displayed label is a translation. For example, a notification_frequency column storing freq.daily, with labels living in messages.en.yml:
freq.daily: "Once a day"
freq.weekly: "Once a week"
$builder->add('frequency', TranslationChoiceType::class, [
'domain' => 'messages',
]);
The point is that the DB stores freq.daily (translation-independent, safe to rename the English copy) while users see the translated string. A matching TranslationChoice constraint then rejects any submitted value that isn't a key in that domain.
If the app is Symfony 4+, skip the bundle. ChoiceType with a choice_label closure over the translator does the display half, and a small in_array validator over $translator->getCatalogue()->all($domain) does the rest.
Form type with an explicit domain. Always pass domain explicitly rather than relying on a default — the default is usually messages, and silent drift here is how you end up with a dropdown rendering raw keys in staging.
$builder
->add('frequency', TranslationChoiceType::class, [
'domain' => 'settings',
'choice_label' => fn ($key) => $key, // label resolved by the type itself
'placeholder' => 'settings.choose',
]);
Pair the type with the constraint, every time. The form type only controls what is offered; the constraint controls what is accepted. Never rely on the dropdown alone — a POST can contain anything.
use Liip\TranslationChoiceBundle\Validator\Constraints\TranslationChoice;
class SettingsDto
{
/**
* @TranslationChoice(domain="settings")
*/
public $frequency;
}
Use it where keys are the contract, not where labels are content. Good fits: enum-like columns, feature flags, notification channels, shipping methods. Bad fits: anything a content editor should be able to add to (a country list, a category tree) — those belong in an entity table, not a YAML file, because adding a value requires a deploy.
Share one domain per form, not one domain per field. Create a dedicated domain (settings, checkout, onboarding) so the constraint's "set of valid keys" stays small and scoped. A constraint pointed at messages validates against your entire message catalogue, which is both slow and semantically meaningless.
Compose it with a data transformer when the stored value differs from the key. If the DB stores an integer but you want translatable labels, put a CallbackTransformer in front of the choice type and let the bundle handle only the label rendering:
$builder->add('channel', TranslationChoiceType::class, ['domain' => 'channels'])
->addModelTransformer(new ChannelKeyToIdTransformer($channels));
Reuse the validator standalone. The constraint is useful outside forms — validating an imported CSV row, an API payload, a console command argument. Inject the validator and validate a scalar wrapper:
$violations = $this->validator->validate(
['value' => $payload['frequency']],
new Assert\Collection(['value' => [new TranslationChoice(['domain' => 'settings'])]])
);
Test the two failure modes explicitly, not the happy path. Assert (a) submitting a key present in the domain passes, and (b) submitting a key that exists in a different domain fails. That second test catches the most common misconfiguration — a forgotten domain option inheriting messages.
public function testRejectsKeyFromOtherDomain(): void
{
$form = $this->factory->create(SettingsType::class);
$form->submit(['frequency' => 'checkout.paypal']); // exists, wrong domain
$this->assertFalse($form->isValid());
}
Rendering. The type produces ordinary choices, so form_widget(form.frequency) works with no theme. If labels come out untranslated, the translator isn't being invoked with the domain you think — dump the compiled choice view with {{ dump(form.frequency.vars.choices) }} before touching the template.
getName(), setDefaultOptions() instead of configureOptions()) will break under modern Symfony. Budget for a reimplementation rather than a port.messages.en.yml but absent from messages.de.yml will show freq.daily to German users and validate fine. Add a CI check that all keys in your operational domains exist in every locale (bin/console debug:translation <locale> <domain>).freq.custom: "Every %count% days" cannot be used as a choice label — there are no parameters at render time. Keep choice keys parameter-free.freq.daily to frequency.daily invalidates every stored row silently; the constraint will reject them on the next edit. Treat keys as a schema and migrate with SQL, not by editing YAML.placeholder submits '' or null. Either make the constraint allowEmpty/skip on null, or drop the placeholder for required fields — otherwise every optional dropdown becomes a validation bug.messages domain makes every render slower. Another reason to use narrow, purpose-built domains.kernel.request listeners that call setLocale must run first.ChoiceType with 'choices' => array_combine($keys, $keys) and 'choice_label' => fn ($k) => $translator->trans($k, [], $domain), plus a Constraint validator doing array_key_exists($value, $translator->getCatalogue($locale)->all($domain)). That's the whole bundle, and now you own it.choice_loader set to a custom LoaderInterface if you need dynamic/filtered key sets (e.g. only keys the current tenant is licensed for) without forking the bundle.How can I help you explore Laravel packages today?