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']);