Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 27 additions & 1 deletion src/Serializer/TranslatableItemDenormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<array-key, mixed> $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.
*
Expand Down
18 changes: 18 additions & 0 deletions tests/Serializer/TranslatableItemDenormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading