Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Formtranslationchoice Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Install from the archived tag, not a floating constraint. There is no maintained branch and no Flex recipe:

    "require": {
        "liip/formtranslationchoice-bundle": "dev-master"
    }
    
  2. Register the bundle manually in AppKernel::registerBundles():

    new Liip\TranslationChoiceBundle\LiipTranslationChoiceBundle(),
    
  3. 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).

  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • It is archived. No security fixes, no PHP 8 / Symfony 6 compatibility guarantees, and the Symfony2-era API (getName(), setDefaultOptions() instead of configureOptions()) will break under modern Symfony. Budget for a reimplementation rather than a port.
  • Zero dependents means zero field-testing. There is no ecosystem of bug reports to learn from. Read both classes end-to-end before shipping; the entire surface area is a few hundred lines.
  • Missing translations render the raw key, and the constraint still accepts it. A key present in 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>).
  • Parameterised messages are a trap. A key like freq.custom: "Every %count% days" cannot be used as a choice label — there are no parameters at render time. Keep choice keys parameter-free.
  • The choice value is the key, so renaming a key is a data migration. Changing 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.
  • Empty/placeholder values and the constraint. A nullable field with a 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.
  • Full-catalogue loading on every form build. The type reads the whole domain to build choices, so a 500-key messages domain makes every render slower. Another reason to use narrow, purpose-built domains.
  • Catalogue caching and locale switching. The translator caches per-locale; if you build a form before switching locale (e.g. in a listener), labels freeze in the wrong language. Build forms after the locale is resolved — kernel.request listeners that call setLocale must run first.
  • Reimplementing it is cheap and probably correct. In a modern app: 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.
  • Extension point if you must stay: the type is a thin wrapper — override it as a service with 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
cgv/localized-resource