koff/i18n-form-bundle
A Symfony bundle that adds internationalization support to forms, helping developers build locale-aware fields and manage translated form data in multilingual applications. It simplifies rendering and handling inputs across locales while integrating with Symfony’s form component.
Read this first: koff/i18n-form-bundle is a Symfony bundle, not a Laravel package. The name says "bundle," the dependencies are Symfony Form + Doctrine ORM, and it was built to add per-locale translation widgets to Symfony forms for Doctrine entities implementing a translatable interface (the KnpLabs/DoctrineBehaviors or A2lix style $entity->getTranslations() collection). There is no service provider, no facade, no Eloquent support, and no config file an artisan vendor:publish could find. Treat this assessment as "what would it take, and what should I do instead."
Minimal evaluation steps if you're assessing it on behalf of a Symfony team:
composer.json and read the require and require-dev blocks. Note the PHP constraint and Symfony version range — this is the single most decisive file in the repo.ls src/ and look for one or two Form/Type classes and a DependencyInjection/ extension. That is the whole package: form types that render locale tabs and map submitted values onto translation children.tests/ — if it's thin or absent, assume the form-type behavior is untested against your Symfony/PHP combination.composer require koff/i18n-form-bundle inside a throwaway Symfony 3.4/4.x skeleton with a translatable entity, because a modern Symfony app will reject it on constraints.First use case, if it worked: a ProductType form with a tab per locale (en, fr, de) where title/description submit into the entity's translation collection and persist in one flush. From a Laravel chair, the equivalent day-one task is a resource form with locale tabs over a JSON column — see the patterns below, which is the code you would actually ship.
For a Laravel app, do not attempt to vendor this. The realistic mapping is:
| Symfony/Doctrine concern | Laravel equivalent |
|---|---|
| Translatable entity + translation collection | spatie/laravel-translatable (JSON column) or astrotomic/laravel-translatable (side table) |
| Symfony Form type with locale tabs | Blade/Inertia form + @foreach(config('app.locales')) |
| Form-level translation mapping | FormRequest rules keyed by locale |
| Locale routing/switching | mcamara/laravel-localization |
If you are in a Symfony codebase that already uses it, the pattern is: register the bundle in AppKernel/bundles.php, make your entity translatable, then use the bundle's form type as a field or as the parent type so each locale gets a fieldset. Persist with cascade: ["persist"] on the translations association, and put locale-required validation in a dedicated validation group so you can require en but not de.
If you are in Laravel (the assumed case), here are the patterns that solve the same problem with maintained code.
Single-table JSON translations — the cheapest and most common:
// migration
$table->json('title')->nullable();
$table->json('body')->nullable();
// model
use Spatie\Translatable\HasTranslations;
public array $translatable = ['title', 'body'];
Side-table translations — when translations need their own columns, slugs, or per-locale publishing state. Use astrotomic/laravel-translatable and a product_translations table with a unique index on (product_id, locale).
Form request with per-locale rules, which is where most of the real work lives:
public function rules(): array
{
$rules = ['price' => ['required', 'numeric']];
foreach (config('app.locales') as $locale) {
$required = $locale === config('app.fallback_locale') ? 'required' : 'nullable';
$rules["title.$locale"] = [$required, 'string', 'max:255'];
$rules["body.$locale"] = [$required, 'string'];
}
return $rules;
}
Locale-tab UI with a Blade partial, so the tab markup and the validation error keys stay in sync:
@foreach (config('app.locales') as $locale)
<x-tab :id="$locale" :label="strtoupper($locale)">
<input name="title[{{ $locale }}]"
value="{{ old("title.$locale", $product->getTranslation('title', $locale)) }}">
@error("title.$locale") <span class="text-red-600">{{ $message }}</span> @enderror
</x-tab>
@endforeach
Fallback-aware reads belong in the model or a presenter, not the view: $product->getTranslation('title', app()->getLocale()) already falls back with Spatie; with a side table, write an accessor that queries fallback_locale when the current locale row is missing.
Admin panels (Filament, Nova, Backpack) all ship a repeatable/tabs field — wire the same title.{locale} array shape into it and you get the Symfony-bundle experience without the bundle.
The version wall is the first thing you will hit. Last release January 2018 means the constraints target PHP 5.6/7.0–7.2 and Symfony 2.8/3.x. Composer will refuse to install on PHP 8.1+ or Symfony 5/6/7, and --ignore-platform-reqs just moves the fatal error to runtime. There is no Symfony 5+ support and no plan for it visible in the repo.
Zero dependents and seven stars is a maintenance signal, not a quality judgment. It means no one has filed the edge-case bugs you are about to rediscover, there are no security advisories, and no Stack Overflow answers exist for your error message. Budget for reading the source rather than searching for it.
Do not try to bridge Doctrine into Laravel. doctrine/dbal gives you a query builder, not an EntityManager, and this bundle's form types are bound to symfony/form. There is no adapter and writing one is a multi-week project to avoid a two-hour task.
Silent translation loss on submit is the classic failure in every implementation of this pattern: empty locale fields create empty translation rows rather than being skipped, and the fallback locale then "wins" because it's the only non-empty value. Filter empty locale arrays in the request or a prepareForValidation() hook before they reach the model.
Field-name/index mismatch breaks collection mapping — if the form renders title[fr] but your mapping expects index 1, you get a null translation with no error. Always dump() the bound collection immediately before persist the first time you wire a new entity.
Cascade settings: in Doctrine, cascade: ["persist"] without "remove" leaves orphan translation rows on delete. In Laravel, use $table->foreignId(...)->cascadeOnDelete() — same class of bug, different syntax.
Fallback locale reads are lossy by design. Both Spatie and side-table packages return the fallback when a locale is missing, which hides incomplete translations from editors. Add an admin-only "missing translations" report querying whereNull("title->{$locale}") (JSON) or a leftJoin on the translations table, or your content team will never finish a locale.
Where extension would go, if you fork it: the form theme (Resources/views/Form/) controls tab markup, and the form type is the place to inject locale ordering or per-locale validation groups. Forking means owning the Symfony version bump yourself — genuinely a day's work for small surface area, but it is work you will do again on the next major.
Bottom line: the pattern this package implements is sound and worth copying; the package itself is not a dependency you can responsibly add in 2025. Reimplement the locale-tab form against spatie/laravel-translatable and keep your dependency graph honest.
How can I help you explore Laravel packages today?