feat(serializer)!: reconcile nested translation writes in place#81
Merged
paullla merged 1 commit intoJul 7, 2026
Merged
Conversation
df34400 to
5c45e8f
Compare
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.
5c45e8f to
1b1ea7d
Compare
paullla
added a commit
that referenced
this pull request
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
API Platform denormalizes the locale-keyed
translationsmap 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 aPATCHmeant 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
TranslatableItemDenormalizer: it intercepts a translatable payload carryingtranslations, 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 aPATCH, and remove them onPUT(HTTP replace semantics).getLocale()rather thangetTranslations()->get($locale), so behavior does not depend on the user mappingindexBy: locale. A plain OneToMany hydrates as a 0-indexed list, and the model trait already matches the same way.localein the request body no longer relabels (and, onPUT, orphan-removes) the row addressed by the key.PUTrequiresstandard_putto be disabled, per operation (extraProperties: ['standard_put' => false]) or globally (api_platform.defaults.extra_properties.standard_put: false): withstandard_puton, API Platform deserializes into a fresh object and its DoctrinePersistProcessorreflection-copies every property, including thetranslationscollection, over the managed entity, so rows cannot be matched at any layer. The denormalizer detects that misconfiguration and throws aLogicExceptionnaming 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.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.upstream/master(e071ef7); CI green on the full matrix including the lowest-dependencies leg.