From 4cdeb3864e91b92093d72e6b60fa6c857c08ab52 Mon Sep 17 00:00:00 2001 From: Paula Date: Tue, 7 Jul 2026 14:36:49 +0200 Subject: [PATCH] fix(serializer): leave IRI-referenced translations to native denormalization TranslatableItemDenormalizer claimed every payload whose translations key was an array, including a list of IRI strings (translations exposed as their own ApiResource, as in #40). It then skipped the string items, so the references were silently dropped on POST and PATCH, and PUT removed every existing translation because no submitted locale was recorded. Before 2.0.0 API Platform resolved such IRIs natively. Restrict supportsDenormalization to the embedded shape (every item a document): IRI lists and mixed payloads now take the native path again, restoring the pre-2.0 behavior for that shape. The in-loop skip stays as defense for direct denormalize() calls. --- CHANGELOG.md | 6 ++++ .../TranslatableItemDenormalizer.php | 28 ++++++++++++++++++- .../TranslatableItemDenormalizerTest.php | 18 ++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2542bc..0584460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Translations referenced by IRI (translation exposed as its own `ApiResource`) + are again resolved by API Platform's native denormalization; 2.0.0 + intercepted such payloads and silently dropped the references, removing every + existing translation on `PUT` + ## [2.0.0] - 2026-07-07 See [UPGRADE-2.0.md](UPGRADE-2.0.md) for upgrade instructions. diff --git a/src/Serializer/TranslatableItemDenormalizer.php b/src/Serializer/TranslatableItemDenormalizer.php index 5538875..8ed5c16 100644 --- a/src/Serializer/TranslatableItemDenormalizer.php +++ b/src/Serializer/TranslatableItemDenormalizer.php @@ -14,6 +14,10 @@ /** * Denormalizes the locale-keyed `translations` map of a translatable resource. * + * Applies only to the embedded shape (translation documents keyed by locale, or a list + * of documents carrying `locale`); payloads referencing translations by IRI are left to + * API Platform's native denormalization (see isEmbeddedTranslationsMap()). + * * Without this, API Platform denormalizes the map as plain objects with no identity, * so it recreates every row (ignoring `id`) and orphan-removes locales absent from the * payload. This denormalizer intercepts a translatable, handles the `translations` map @@ -45,7 +49,8 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form && isset($data['translations']) && \is_array($data['translations']) && is_a($type, TranslatableInterface::class, true) - && empty($context[self::ALREADY_CALLED]); + && empty($context[self::ALREADY_CALLED]) + && $this->isEmbeddedTranslationsMap($data['translations']); } public function getSupportedTypes(?string $format): array @@ -131,6 +136,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return $translatable; } + /** + * Only the embedded shape (every item a document, keyed by locale or carrying one) + * is this denormalizer's business. Translations referenced by IRI strings, when the + * translation is exposed as its own ApiResource, must go through API Platform's + * native denormalization so the IRIs resolve to the existing entities; intercepting + * them here would silently drop them (and, on PUT, remove every existing + * translation). Mixed shapes are ambiguous and take the native path too. + * + * @param array $translations + */ + private function isEmbeddedTranslationsMap(array $translations): bool + { + foreach ($translations as $translation) { + if (!\is_array($translation)) { + return false; + } + } + + return true; + } + /** * Matches an existing translation by locale. * diff --git a/tests/Serializer/TranslatableItemDenormalizerTest.php b/tests/Serializer/TranslatableItemDenormalizerTest.php index ea5104b..d4cce77 100644 --- a/tests/Serializer/TranslatableItemDenormalizerTest.php +++ b/tests/Serializer/TranslatableItemDenormalizerTest.php @@ -37,6 +37,24 @@ public function testSupportsOnlyTranslatablePayloadsCarryingTranslations(): void )); } + public function testLeavesIriReferencedTranslationsToTheNativeDenormalizer(): void + { + // Translations referenced by IRI (translation exposed as its own ApiResource) + // are not the embedded locale-keyed shape this denormalizer handles. It must + // step aside so API Platform resolves the IRIs natively; intercepting would + // silently drop them (and, on PUT, remove every existing translation). + self::assertFalse($this->denormalizer->supportsDenormalization( + ['translations' => ['/api/dummy_translations/1', '/api/dummy_translations/2']], + DummyTranslatable::class, + )); + + // Mixed shapes are ambiguous: hand the whole payload to the native path too. + self::assertFalse($this->denormalizer->supportsDenormalization( + ['translations' => ['/api/dummy_translations/1', 'en' => ['translation' => 'EN']]], + DummyTranslatable::class, + )); + } + public function testPatchPopulatesExistingTranslationInPlaceAndKeepsAbsentLocales(): void { $translatable = $this->translatableWith(['en' => 'EN', 'de' => 'DE']);