Skip to content

feat(serializer)!: reconcile nested translation writes in place#81

Merged
paullla merged 1 commit into
Locastic:masterfrom
paullla:feat/nested-translations-in-place-write
Jul 7, 2026
Merged

feat(serializer)!: reconcile nested translation writes in place#81
paullla merged 1 commit into
Locastic:masterfrom
paullla:feat/nested-translations-in-place-write

Conversation

@paullla

@paullla paullla commented Jul 7, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? yes
New feature? yes
BC breaks? yes
Deprecations? no
Issues fixes #61, relates to #36, #54
License MIT

Problem

API Platform denormalizes the locale-keyed translations map as plain objects with no identity. On a write it recreates every translation row (ignoring the submitted id) and orphan-removes any locale missing from the payload, so a PATCH meant to edit one locale silently drops the rest, and re-writing a resource violates the (translatable_id, locale) unique constraint (#61) and churns translation ids (#36, #54).

Changes

  • Add TranslatableItemDenormalizer: it intercepts a translatable payload carrying translations, lets API Platform build and populate the entity, then reconciles the map itself: populate the existing row per locale in place (stable id), create only genuinely new locales, keep locales absent from a PATCH, and remove them on PUT (HTTP replace semantics).
  • Match existing translations by iterating and comparing getLocale() rather than getTranslations()->get($locale), so behavior does not depend on the user mapping indexBy: locale. A plain OneToMany hydrates as a 0-indexed list, and the model trait already matches the same way.
  • Treat the map key as the authoritative locale: a locale in the request body no longer relabels (and, on PUT, orphan-removes) the row addressed by the key.
  • PUT requires standard_put to be disabled, per operation (extraProperties: ['standard_put' => false]) or globally (api_platform.defaults.extra_properties.standard_put: false): with standard_put on, API Platform deserializes into a fresh object and its Doctrine PersistProcessor reflection-copies every property, including the translations collection, over the managed entity, so rows cannot be matched at any layer. The denormalizer detects that misconfiguration and throws a LogicException naming the fix, instead of letting the write die in the persist layer with an obscure Doctrine error. PATCH (the recommended edit verb) needs no configuration. Documented in the README and UPGRADE-2.0.
  • Tests: unit tests for the merge, replace, create, in-place-id, list-shaped and misconfigured-PUT paths, plus a Doctrine integration test (in-memory sqlite and the real Symfony serializer) asserting id stability, orphan removal and map-key precedence against hydrated PersistentCollections, with the fixture intentionally not indexed by locale.
  • CI: raise the PHPStan memory limit, needed now that the first mapped entities give phpstan-doctrine real work, and add one scoped ignore for the interface-typed owning side that property invariance forbids narrowing in the fixture.

Verification

  • bin/phpunit: 65 tests, 129 assertions, green (9 pre-existing deprecations from legacy data providers).
  • bin/phpstan analyse --memory-limit=512M: no errors (level 6).
  • bin/php-cs-fixer check: no violations.
  • composer validate --strict: valid.
  • Rebased on upstream/master (e071ef7); CI green on the full matrix including the lowest-dependencies leg.

@paullla paullla force-pushed the feat/nested-translations-in-place-write branch 4 times, most recently from df34400 to 5c45e8f Compare July 7, 2026 11:21
API Platform denormalizes the locale-keyed translations map as plain
objects with no identity, so writes recreated every translation row
(ignoring the submitted id) and orphan-removed any locale missing from
the payload. A PATCH meant to edit one locale silently dropped the rest,
and re-writing a resource violated the (translatable_id, locale) unique
constraint (Locastic#61) and churned translation ids (Locastic#36, Locastic#54).

Add a TranslatableItemDenormalizer that intercepts a translatable
payload carrying translations, lets API Platform build and populate the
entity, then reconciles the map itself:

- populate the existing translation per locale in place (stable id)
- create a translation only for genuinely new locales
- PATCH keeps locales absent from the payload (partial edit)
- PUT removes them (full replace), following HTTP semantics

Match existing translations by iterating and comparing getLocale()
rather than getTranslations()->get($locale), so behavior does not depend
on the user mapping indexBy: locale; a plain OneToMany hydrates as a
0-indexed list, and the model trait matches the same way. Treat the map
key as the authoritative locale so a locale in the body cannot relabel
(or, on PUT, orphan-remove) the row addressed by the key.

PUT requires standard_put to be disabled (per operation or via
api_platform.defaults.extra_properties.standard_put): with standard_put
on, API Platform deserializes into a fresh object and its Doctrine
PersistProcessor reflection-copies every property, including the
translations collection, over the managed entity, so rows cannot be
matched at any layer. The denormalizer detects that misconfiguration and
throws a LogicException with the fix instead of letting the write die in
the persist layer. Documented in the README and UPGRADE-2.0 and shown in
the example resource.

Covered by unit tests for the merge, replace, create, in-place-id,
list-shaped and misconfigured-PUT paths, plus a Doctrine integration
test (in-memory sqlite and the real Symfony serializer) asserting id
stability, orphan removal and map-key precedence against hydrated
PersistentCollections, with the fixture intentionally not indexed by
locale.
@paullla paullla force-pushed the feat/nested-translations-in-place-write branch from 5c45e8f to 1b1ea7d Compare July 7, 2026 11:41
@paullla paullla merged commit ce9d3c2 into Locastic:master Jul 7, 2026
6 checks passed
paullla added a commit that referenced this pull request Jul 7, 2026
Rename the Unreleased section to 2.0.0, add the missing entries for the
community files (#79), the edge-case and kernel tests (#80) and the
misconfigured-PUT guard (#81), and add Keep a Changelog compare links.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unique constraint " name + locale "

1 participant