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

Doctrine Translatable Form Bundle Laravel Package

simettric/doctrine-translatable-form-bundle

Symfony bundle that adds form support for Doctrine translatable entities, making it easier to create and edit translated fields in forms. It integrates translatable behavior with Symfony forms so multilingual content can be managed cleanly.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: despite the "FormBundle" name implying generic form helpers, this is a Symfony bundle (registered via AppKernel / bundles.php), not a Laravel package. There is no service provider, no config publish, no Blade views. If your project is Laravel, the only takeaway is the pattern (a form type that fans translated fields out per locale) — you will not install it.

What it actually solves: it wires Symfony's Form component to a Doctrine entity that is translated through a translation collection ($entity->getTranslations(), a Translation entity per locale — the KnpLabs/DoctrineBehaviors or Gedmo style of translatable). Instead of hand-building translations[en][title], translations[it][title] fields, the bundle provides a form type that produces the per-locale sub-forms and maps submitted data back onto the right Translation object.

Prerequisites before you touch it:

  1. A working translatable entity + Translation entity (translation table, translatable mapping, locale column). The bundle does not create or migrate that schema for you.
  2. A Symfony version it actually supports. Last release was 2017-09-18, so expect Symfony 2.8/3.x-era code (getName() vs getBlockPrefix(), choices_as_values, etc.). Read composer.json before requiring — do not let Composer's resolver "help" you into a broken state.
  3. A concrete list of locales. Almost every usage of this bundle needs ['en', 'it', 'de'] passed in explicitly; it cannot infer them.

Install and register:

composer require simettric/doctrine-translatable-form-bundle
// Symfony 2/3 app/AppKernel.php
$bundles[] = new Simettric\DoctrineTranslatableFormBundle\SimettricDoctrineTranslatableFormBundle();

Where to look first in the repo: src/Form/Type/ — open the main type and read configureOptions() / buildForm() before writing any code. The README's usage snippet is short; the option names (locales, translatable_fields, or whatever this version calls them) are authoritative only in that class. Run bin/console debug:form <TypeName> against your install to see the resolved options and children.

First use case — the admin "edit product in 3 languages" screen:

// 1. A small type describing one translation's fields.
class ProductTranslationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', TextType::class)
            ->add('description', TextareaType::class);
    }
}

// 2. The parent form: non-translatable fields + the translatable bundle type.
$form = $this->createForm(ProductType::class, $product, [
    'locales' => ['en', 'it', 'de'],
]);

Render it as tabs (one per locale) and persist with $em->flush() after making sure new translations are merged (see Gotchas).

Implementation Patterns

Separate the two concerns. Do not put translated fields on the parent entity form. Create two types: ProductType (SKU, price, category, plus the translatable sub-form) and ProductTranslationType (title, slug, description, per-locale image). The translation type is reusable, testable, and is the thing you'll actually edit over time.

Tabbed locale UI with a single submit. The common shape is:

<ul class="nav nav-tabs">
  {% for locale in locales %}
    <li class="nav-item"><a class="nav-link" data-toggle="tab" href="#{{ locale }}">{{ locale|upper }}</a></li>
  {% endfor %}
</ul>
<div class="tab-content">
  {% for locale in locales %}
    <div class="tab-pane" id="{{ locale }}">
      {{ form_row(form.translations[locale].title) }}
      {{ form_row(form.translations[locale].description) }}
    </div>
  {% endfor %}
</div>

Confirm the child naming by dumping the form view ({{ dump(form.translations.vars.children|keys) }}) rather than guessing — some versions key children by locale, others by numeric index with a locale field.

Non-translatable + translatable in one form. Keep ProductType as the single form the controller handles. Put global fields at the top level and the translation bundle type as a child, so one POST updates the product and all locales. Validation then needs care (below).

Controller flow that survives real data:

$product = $repository->find($id) ?? new Product();
// Ensure a Translation object exists for every configured locale,
// otherwise fields render empty and submitted values attach to nothing.
$product->mergeNewTranslations();

$form = $this->createForm(ProductType::class, $product, ['locales' => $locales]);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $em->persist($product);
    $em->flush();          // DoctrineBehaviors persist listeners fire here
    return $this->redirectToRoute('product_edit', ['id' => $product->getId()]);
}

Validation per locale. Attach constraints to the Translation entity, and put a @Valid (or Valid::class) constraint on the translations property of the parent. If title is required in en but optional in it, use validation_groups on the translation type or a callback constraint that reads $this->getLocale() — don't reach for required => true alone, it only affects HTML, not isValid().

Uploads per translation. A translated image is one file per locale. Use VichUploaderBundle mapped on the Translation entity, not the parent, and call $translation->setImageFile($file) in the type. If you map the upload on the parent, all locales will point at the last uploaded file.

API / PATCH usage. For JSON endpoints, submit with $form->submit($data, false) (clearMissing = false) so a client sending only it doesn't blank en. Keep the same type and locales option so form and API stay in sync — that's the main payoff of centralizing this in a form type.

Extending. If you need conditional fields (e.g. slug auto-derived from title per locale), add a PRE_SUBMIT/POST_SUBMIT listener on the translation type, not on the bundle type. Treat the bundle's type as a black box you compose around.

Gotchas and Tips

  • It is effectively unmaintained. Five stars, zero dependents, last release 2017. There will be no fix for a Symfony 5/6/7 BC break. Budget for a fork ("repositories": [{"type": "vcs", ...}]) or, more honestly, reimplement the ~100 lines of glue yourself — a loop over locales adding a sub-form is not much more code and you own it.
  • The popular alternative is a2lix/translation-form-bundle. If you're choosing today rather than inheriting this, evaluate that first; it solves the same problem with a maintained codebase and more documentation.
  • mergeNewTranslations() is the classic bug. KnpLabs-style entities only persist new locale rows when the entity's new-translations collection is merged. Skip it and your form will happily render it and silently never save it.
  • Empty translation rows. Proxied/placeholder translations get persisted as blank strings and pollute the table. Either filter empty locales before flush or accept it and add a cleanup command. This is the first thing to check when a translator reports "it saved but the frontend shows English".
  • Locale list drift. The locales option, framework.translator.fallbacks, and your DB rows are three separate sources of truth. Define one parameter (e.g. %app.locales%) and inject it into the form options, the Twig tabs, and any locale switcher.
  • by_reference and collection replacement. If a customization replaces translations with a new collection rather than mutating it, set by_reference => false on that field or Doctrine won't see the change.
  • Form naming collisions. Embedding the translation type twice in one page (e.g. a modal + the tabbed form) collides on field names and CSRF tokens. Namespace them with createNamedBuilder or distinct block_name/name options.
  • Debugging recipe: dump($form->getData()->getTranslations()) before render, dump($request->request->all()) on submit, and the Symfony form data collector in the profiler (it shows the data → view → model mapping per child). bin/console debug:form YourType --show-metadata reveals inherited options from the bundle type, which is how you discover option names the README omits.
  • Symfony version pinning. Old form types often still implement getName() or use removed options. If you see OptionsResolver "undefined option" or getBlockPrefix errors, you're on a Symfony version newer than the code targets — stop and pin rather than patching call sites one by one.
  • currentLocale leakage. Some translatable implementations keep a mutable currentLocale on the entity. If you switch it in a listener or template helper, reset it after the form renders or subsequent queries against the same managed entity will return the wrong translation.
  • Test it explicitly. A KernelTestCase that builds the form with two locales and submits a payload is worth more than manual clicking here, because the failure mode is silent (row not written) rather than an exception.
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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource