tunet/api-platform-translation-bundle
A Symfony bundle that adds multilingual translation support to API Platform resources, helping you expose localized field values through REST and GraphQL APIs with Doctrine integration and configurable fallbacks.
This is a Symfony bundle (not Laravel) — it targets Symfony 6/7 + API Platform 3 + Doctrine ORM. If you're coming from Laravel, treat it as the API Platform equivalent of dimsav/laravel-translatable / Spatie-style translation tables: one base entity, one *Translation entity per locale, joined by a OneToMany.
Minimum setup:
composer require tunet/api-platform-translation-bundle
// config/bundles.php
Tunet\ApiPlatformTranslationBundle\ApiPlatformTranslationBundle::class => ['all' => true],
# config/packages/tunet_api_platform_translation.yaml
tunet_api_platform_translation:
locales: ['en', 'fr', 'de']
default_locale: en
fallback_locale: en
Where to look first:
TranslatableInterface / TranslationInterface contracts shipped in the bundle — these mirror Sylius. Your entity implements the former, your *Translation entity implements the latter.TranslatableTrait / TranslationTrait — copy their property layout (typically $translations collection + $currentLocale, and $id, $locale, $translatable on the translation side).LocaleProvider service — this is what decides which translation gets serialized on read. Check it before wiring anything else; it drives most of the "why is the wrong language coming back" issues.First use case — the canonical one is a Product with a ProductTranslation that holds name and description. You keep price, sku, createdAt on the parent (locale-agnostic) and move everything human-readable into the translation entity.
Entity shape. Parent holds scalars that don't vary per locale; translation holds the localised text. Give the parent a getTranslations() / addTranslation() / removeTranslation() and a getTranslation(string $locale = null) helper returning the requested one (or the current locale, or the fallback).
#[ApiResource(
normalizationContext: ['groups' => ['product:read']],
denormalizationContext: ['groups' => ['product:write']],
)]
class Product implements TranslatableInterface
{
use TranslatableTrait;
#[ORM\OneToMany(targetEntity: ProductTranslation::class, mappedBy: 'translatable', cascade: ['persist', 'remove'])]
protected Collection $translations;
}
#[ApiResource(
uriTemplate: '/products/{id}/translations',
operations: [new Get(), new GetCollection(), new Put(), new Delete()],
)]
class ProductTranslation implements TranslationInterface { /* name, description, locale, translatable */ }
Read pattern. Clients ask for a language via Accept-Language (preferred) or ?locale=fr. The bundle's locale provider resolves it and the serializer emits the matching translation, flattened so name/description appear at the product level — the client never sees the join. Document this in your OpenAPI description; consumers will be confused otherwise.
Write pattern. You generally have two working styles:
{"name": ..., "description": ...} on POST /products with the locale from the header, and have a state processor create the ProductTranslation. This is the ergonomic API and what you want for admin UIs.POST /products/{id}/translations with an explicit locale field. Verbose but ideal for bulk import / translation tooling.Support at least one explicitly; mixing the two silently is the single biggest source of confusion.
Serializer groups. Keep product:read and product:write separate, and put name/description in both — but only emit the flattened fields via the locale-aware normalizer. translations collection itself should only be exposed in an admin group; otherwise you leak every locale and defeat the whole point.
Validation. Use Symfony's Valid constraint on the $translations collection, and NotBlank on the translation's name. The UniqueEntity on (translatable_id, locale) matters — the bundle will let you POST two en translations otherwise.
Integration with API Platform state processors. If you need to derive slug from the localised name, do it in a Processor that reads $request->attributes->get('locale') — the bundle sets this before your processor runs.
config/translatable.php, no Eloquent magic. If you're actually on Laravel with api-platform/laravel, expect to wire the Doctrine mapping yourself; the bundle's service definitions assume the Symfony container.composer.json, and write integration tests around the read/write flows you depend on before shipping. Don't assume semantic versioning guarantees.Accept-Language header that API Platform's _locale negotiation picked up before your bundle did — or a caching layer stripping the header entirely.fr translation exists but name is empty, you usually want fallback to en, not an empty string. The bundle follows Sylius's getTranslation() semantics which return the entity, not a merged copy — do the field-level fallback in a custom normalizer if you need it.cascade: ['persist', 'remove'] on $translations is required; without remove, deleting a product leaves orphan translations and Doctrine throws a FK violation on a fresh migration.name and the full translations collection in the same group, JSON will contain both — and clients will build features around the wrong one. Be strict: flattened only, or collection only.LocaleProviderInterface implementation that reads from a custom header (e.g. X-Store-Locale) if you're multi-tenant or building a store-switcher. This is cleaner than fighting Accept-Language.CurrentLocale is request-scoped. If you spawn async work (messenger, queue) mid-request and read a translatable, the locale may be lost. Re-hydrate it explicitly from a job argument.Product. Run doctrine:migrations:diff immediately after wiring the entity; the bundle ships no migrations of its own.bin/console debug:container tunet_api_platform_translation (or grep services.xml in the bundle) to see exactly which services are registered and their tags — faster than reasoning from the README, which is thin.How can I help you explore Laravel packages today?