hostnet/entity-translation-bundle
A Symfony bundle for translating Doctrine entity fields per locale. Store and retrieve localized entity content with minimal setup, making multilingual applications easier to build and maintain.
What it is (and isn't). This bundle renders human-readable labels for enum-like classes — classes whose constants represent a fixed set of values (OrderStatus::PENDING, Gender::MALE). It reads metadata on the class and its constants, then runs the resulting key through Symfony's translator. It is not a Doctrine entity-translation solution: it does not store per-row translated content, so don't reach for it when you need translated database columns.
Version floor moved (1.0.11). The release drops Symfony < 6.4 and PHP < 8.3. If your app is still on PHP 8.1/8.2 or Symfony 6.0–6.3, pin to the previous 1.0.x line — this was published as a patch version but is breaking for older stacks. Check composer why-not hostnet/entity-translation-bundle 1.0.11 before running composer update broadly.
Prerequisites. PHP 8.3+, Symfony 6.4+, and symfony/translation. Note what changed: 1.0.11 removes the doctrine/annotations dependency, which the package declared but never actually used in code. That dependency was doing you an accidental favour — it guaranteed an annotation reader was installed. It no longer is. If your metadata lives in docblocks, you must now require doctrine/annotations yourself and register a reader. On Symfony 7 the framework wires no annotation reader for you, so this is doubly true.
composer require hostnet/entity-translation-bundle
composer require --dev doctrine/annotations # only if you annotate via docblocks
Register. No Flex recipe at this adoption level (single-digit stars, zero dependents), so add it by hand in config/bundles.php:
Hostnet\Bundle\EntityTranslationBundle\HostnetEntityTranslationBundle::class => ['all' => true],
Where to look first. Read the README, then spend five minutes on two files in vendor/hostnet/entity-translation/src/: the Annotation/ directory (what metadata is actually read, and — critically — which Reader interface it type-hints) and Translator/TranslatorInterface.php. The interface is roughly one screen long and will settle every ambiguity the README leaves open. Confirm what got autowired with bin/console debug:container | grep -i entity_translation.
First use case. A status dropdown. Annotate the class with a domain, annotate each constant with a translation key:
namespace App\Enum;
use Hostnet\Component\EntityTranslation\Annotation as Translation;
/**
* @Translation\Domain("enums")
*/
final class OrderStatus
{
/** @Translation\Translation("order_status.pending") */
public const PENDING = 'pending';
/** @Translation\Translation("order_status.shipped") */
public const SHIPPED = 'shipped';
}
# translations/enums.en.yaml
order_status:
pending: 'Awaiting payment'
shipped: 'On its way'
Then inject the bundle's translator (do not confuse it with Symfony's TranslatorInterface) and translate the constant:
$label = $this->entityTranslator->translate(OrderStatus::class, OrderStatus::PENDING);
If that signature doesn't line up with what you see in TranslatorInterface, you're on a version where the entity instance is the first argument — check before writing more code.
One domain per bounded context, not per class. @Domain("enums") shared across all your enum-likes keeps translation files navigable (enums.en.yaml, enums.nl.yaml). Per-class domains produce a sprawl of tiny files that translators hate.
Key on the constant name, or key on the value — pick one and enforce it. Keying on the value (order_status.pending) survives constant renaming but breaks if you ever change the stored value. Keying on the name is refactor-hostile. Either way, put the decision in a short comment in the enum class so the next developer doesn't mix styles within a file.
Wrap it behind a one-method adapter. Given the ecosystem size and the version-floor churn in 1.0.11, expose your own LabelProvider (or a Translatable interface on your enums) and let the bundle be an implementation detail. Migrating to native TranslatableInterface on backed enums becomes a one-file change instead of a grep across the codebase.
Symfony Forms. The natural fit is choice_label:
$builder->add('status', ChoiceType::class, [
'choices' => OrderStatus::cases(),
'choice_label' => fn ($choice) => $this->labels->for($choice),
]);
For enums that appear in many forms, extract a small EnumType extension so the translation logic lives in one place.
API output. Normalize to ['value' => $status->value, 'label' => $this->labels->for($status)] in a serializer normalizer or a presenter — never leak the raw constant to clients if the UI is expected to display it.
Templates. If the bundle ships a Twig extension, register it and use the filter; if not, add your own 10-line filter over your adapter. Templates should not receive translators via app.request-style globals.
Testing. Assert labels, not keys, in functional tests — that's the behaviour you actually care about. A KernelTestCase with self::getContainer()->get('translator') plus a loop over every constant in the enum is a cheap way to catch missing translations in CI:
foreach ((new \ReflectionClass(OrderStatus::class))->getConstants() as $constant) {
self::assertNotSame($constant, $this->labels->for($constant), 'Missing label for '.$constant);
}
Fallback strategy. Decide explicitly whether a missing translation should return the key (loud, good in dev) or the raw value (quiet, good in prod). Most teams want the key in dev/test and a sensible fallback in prod.
Bootstrap assertion. Given that 1.0.11 makes the metadata reader your responsibility, add a smoke test that translates one known constant and asserts the output differs from the key. It fails loudly at build time if someone re-installs without doctrine/annotations, instead of failing silently in a dropdown months later.
doctrine/annotations as a dependency because the package never referenced it directly. Nothing in the bundle changed how metadata is read — which means if the reader disappears from vendor/ (fresh installs, composer update on a clean lock, CI containers), the bundle finds zero metadata and translates nothing, silently. After upgrading, run composer why doctrine/annotations; if the answer is nothing, and you annotate via docblocks, add it back explicitly.Reader. If your app is attributes-first, this bundle's docblock parser will see nothing regardless — plan for a custom attribute driver rather than debugging translations that were never read.bin/console cache:clear before concluding the bundle is broken.debug:translation. bin/console debug:translation en --domain=enums shows exactly which keys the container knows about. If the key is absent, your domain name or file naming is wrong, not your annotation. If the key is present but the label renders as the key, the reader is the suspect.@Domain value must match the file (enums.en.yaml) and the directory must be one Symfony actually scans (translations/, or whatever framework.translator.default_path says).TranslatorInterface types in the container is a classic autowiring failure. Type-hint the bundle's interface (or use the named alias) and add an explicit bind in services.yaml if needed.order_status.pending returns the key string, which renders as a plausible-looking label in a dropdown until someone notices. The CI loop above is the cheapest guard.composer update on this package as a deliberate act: pin an exact version, read the tags, and assume the next release may move the floor again.TranslatableInterface cover this core use case with no third-party dependency and no annotation reader to lose.How can I help you explore Laravel packages today?