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

Api Platform Translation Bundle Laravel Package

locastic/api-platform-translation-bundle

Symfony bundle that adds multilingual support to API Platform resources. Use the provided traits to define translations for Doctrine entities, then serve locale-aware API responses based on the request's language, with automatic translation hydration and minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require locastic/api-platform-translation-bundle

Flex auto-registers Locastic\ApiPlatformTranslationBundle\ApiPlatformTranslationBundle in config/bundles.php; on a non-Flex app add it manually. Requirements are Symfony (bundle + RequestStack), API Platform, and Doctrine ORM — the translation model is a mapped superclass shipped with XML/annotation mappings, so Doctrine is not optional.

Where to look first

  1. src/Model/ — TranslatableInterface, TranslatableTrait, TranslationInterface, AbstractTranslation. These four files define the whole contract; read them before anything else.
  2. tests/ fixtures — the bundle's own functional tests are the canonical end-to-end example (entity + translation entity + API resource config) and are usually more current than the README.
  3. src/Resources/config/doctrine/ — shows exactly which columns/associations AbstractTranslation already gives you, so you don't re-map id, locale, or translatable by hand.

Minimal setup (three steps)

  1. Create a translation entity per translatable resource, extending AbstractTranslation and adding only the translated fields:
#[ORM\Entity]
#[ORM\UniqueConstraint(name: 'book_translation_locale', columns: ['translatable_id', 'locale'])]
class BookTranslation extends AbstractTranslation
{
    #[ORM\Column(type: 'string')]
    private ?string $title = null;

    // getters/setters
}
  1. Make the main entity implement TranslatableInterface and use TranslatableTrait (collection, current/fallback locale, getTranslation() helper), then wire the OneToMany:
#[ORM\OneToMany(targetEntity: BookTranslation::class, mappedBy: 'translatable', cascade: ['persist'], orphanRemoval: true)]
protected Collection $translations;
  1. Configure the API resource with the translation.groups filter plus your group naming convention:
#[ApiResource(
    attributes: [
        'filters' => ['translation.groups'],
        'normalization_context'   => ['groups' => ['book:read', 'book_translation:read']],
        'denormalization_context' => ['groups' => ['book:write', 'book_translation:write']],
    ]
)]

First use case: a Book with a translatable title. POST /books with {"title": "..."} and Accept-Language: en, then GET /books/1 with Accept-Language: fr — the same row returns the French title. That round-trip is the fastest possible smoke test and validates locale resolution, persistence, and serialization in one go.

Implementation Patterns

One translation entity per resource, one shared abstract class. Don't try to build a generic Translation table with a discriminator unless you genuinely have polymorphic translations — Doctrine inheritance here costs more than it saves. Duplicating a 20-line BookTranslation per resource is normal and idiomatic for this bundle (same as Sylius).

Read/write group split. Put book_translation:read only on translation getters and book_translation:write only on setters. The translation.groups filter pulls the current-locale translation's read groups into the normalization context; if you merge read/write into one group you will leak every locale's payload on reads and accept partial garbage on writes.

Locale resolution. The bundle resolves the current locale from the request (_locale, then Accept-Language) and calls setCurrentLocale() / setFallbackLocale() on the entity. Override these two methods when you need a different source (a query param, a tenant setting, a header your gateway rewrites). Keep setCurrentLocale() free of side effects — it is invoked during hydration and denormalization, sometimes more than once per request.

Write flow. Persist through the parent: POST /books with the translated field in the body creates a translation row for the request locale. For multi-locale writes in one payload (back-office/admin), send the nested translations array explicitly — the listener matches incoming translations by locale and updates-in-place or inserts. This is the pattern to use for importers and fixtures.

Read flow. Front-end clients should never request translations directly; they read the flattened parent payload and change only Accept-Language. Back-office clients request the translation collection. Two different normalization contexts over one resource, not two resources.

Partial updates. PATCH on the parent hits the current-locale translation only. If your admin UI edits several locales, do N requests (one per Accept-Language) rather than trying to build a nested PATCH — nested merge semantics in API Platform for collections are the weak spot here.

Integration tips. Cache aggressively downstream by (resource id, locale) — translation output is fully deterministic per locale, so API Platform's HTTP cache and your reverse proxy both work if you add Vary: Accept-Language. For search, denormalize translated text into the parent row on write (via the translation entity's setter) so you can index without joins.

Gotchas and Tips

Empty or bloated JSON → groups. Nine out of ten problems are a missing translation.groups filter or a group name typo. Symptom: you get the raw translations array instead of merged fields, or you get only the parent's non-translated scalars. bin/console debug:config api_platform won't show group strings, so diff your attributes against the test fixtures.

Initialize the collection. TranslatableTrait gives you the property, not the constructor. Without $this->translations = new ArrayCollection() in your entity's constructor you'll get "must be an instance of Collection" errors on the first insert — and only on inserts, so it survives your smoke test.

Unique constraint on (translatable_id, locale). The listener does a find-or-create; without the DB constraint, concurrent requests produce duplicate translations and then getTranslation() returns whichever row Doctrine happens to hydrate. Add the constraint and handle the resulting UniqueConstraintViolationException if you have real concurrency.

Orphan removal. Set orphanRemoval: true on the OneToMany, otherwise removing a translation from the collection leaves the row behind and it resurfaces in future reads with stale data.

Fallback is read-only, and silent. If the current locale has no translation, the bundle falls back to the configured fallback locale — you get a value with no signal that it's a fallback. If clients must know they're seeing a fallback, add an explicit translatedLocales read-only field rather than parsing behavior out of the response.

Locale on write ≠ locale of data. setCurrentLocale() being driven by Accept-Language means a client sending the wrong header silently writes into the wrong translation row. For machine-to-machine writes, send translations explicitly with locale in each element and treat headers as untrusted.

Version pinning is mandatory. This is a low-adoption bundle (0 dependents) tracking API Platform's internals, which change between 2.x and 3.x. Pin an exact version, and pin your API Platform major. Check the tag's composer.json constraints before upgrading either side. Budget an afternoon to read the listener source whenever you upgrade — you are effectively maintaining a thin integration layer.

Debugging checklist.

  • bin/console debug:event-dispatcher — confirm the Doctrine listener/subscriber is attached (it's how translations get bound to the parent); if you've customized the entity manager or used a second EM, it may not fire.
  • Enable SQL logging on a POST — you should see an INSERT into the translation table and no UPDATE to the parent for the translated columns. If you see the parent getting written, your fields are mapped on the wrong entity.
  • EntityManager::clear() between requests in long-running workers (Messenger consumers, FrankenPHP/RoadRunner): the trait's currentLocale is per-instance state, so reset it or re-fetch entities.

Extension points worth knowing. The serializer context builder and the Doctrine listener are both plain services — decorate the context builder if you want to add a translations group only for authenticated admins, or swap the locale provider instead of overriding setCurrentLocale() if locale resolution is shared across several bundles. Prefer decoration over inheritance here; the internal services are small enough to wrap and stable enough not to break.

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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib