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
2 changes: 1 addition & 1 deletion .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ jobs:
- name: Install dependencies
run: composer update --no-interaction --prefer-dist
- name: PHPStan
run: bin/phpstan analyse --no-progress
run: bin/phpstan analyse --no-progress --memory-limit=512M
- name: Code style
run: bin/php-cs-fixer check --diff
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ See [UPGRADE-2.0.md](UPGRADE-2.0.md) for upgrade instructions.
- When `framework.enabled_locales` is configured, requested locales outside the
list (query parameter or `Accept-Language`) fall back to the default locale (#71)
- README rewritten with PHP attributes and current API Platform metadata (#77)
- **BREAKING** Translatable resources exposing `PUT` must set
`extraProperties: ['standard_put' => false]` on the operation so nested
translations are reconciled against the managed entity instead of a fresh
object (#81)

### Added
- In-place, merge-by-locale denormalization of nested translation writes: each
submitted locale updates its existing translation row (stable id), `PATCH`
keeps locales absent from the payload and `PUT` removes them (#81)
- PHPStan level 6 and php-cs-fixer, enforced in CI (#78)
- CI matrix covering PHP 8.2 to 8.5 plus a lowest-dependencies leg (#76)

Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Example:
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\Collection;
Expand All @@ -58,7 +59,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
new Get(),
new GetCollection(),
new Post(normalizationContext: ['groups' => ['translations']]),
new Put(normalizationContext: ['groups' => ['translations']]),
new Patch(normalizationContext: ['groups' => ['translations']]),
// PUT replaces the resource; standard_put must be off so it edits the
// managed entity instead of building a new one (see the notes below).
new Put(
normalizationContext: ['groups' => ['translations']],
extraProperties: ['standard_put' => false],
),
],
normalizationContext: ['groups' => ['article_read']],
denormalizationContext: ['groups' => ['article_write']],
Expand Down Expand Up @@ -158,6 +165,22 @@ class ArticleTranslation extends AbstractTranslation
- The `translation.groups` filter (registered by this bundle) lets clients request all translation objects in a response via `?groups[]=translations`. Without the `translations` group, responses contain only the requested (or fallback) locale.
- Add the `translations` group to the `normalizationContext` of `POST` and `PUT`/`PATCH` operations, as in the example above, so write operations return all translation objects.

**Editing translations (`PUT` vs `PATCH`):** the bundle populates the submitted translations onto the managed entity, keeping existing translation rows (and their ids) stable, following the HTTP semantics of each method:
- `PATCH` (`application/merge-patch+json`) is a partial edit: it updates the submitted locales and leaves the others untouched. This is the recommended way to edit translations and needs no extra configuration.
- `PUT` is a full replace: locales absent from the payload are removed.

For `PUT` you **must** disable API Platform's `standard_put`, either per operation (`extraProperties: ['standard_put' => false]`, as above) or once for the whole API:

``` yaml
# config/packages/api_platform.yaml
api_platform:
defaults:
extra_properties:
standard_put: false
```

With `standard_put` on, API Platform deserializes into a brand-new object and copies its properties (including the `translations` collection) over the managed entity, so translations cannot be matched to their existing rows. The bundle detects this misconfiguration and fails with an explicit error instead of letting the write die in the persistence layer.

Usage:
------

Expand Down
13 changes: 13 additions & 0 deletions UPGRADE-2.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@
the list now fall back to the default locale instead of being accepted
verbatim (both the `?locale=` query parameter and the `Accept-Language`
header). Without `enabled_locales`, behavior is unchanged.

5. Nested translation writes are now reconciled in place. When a write payload
carries the `translations` map, each submitted locale updates its existing
translation row (keeping the id) instead of the collection being recreated:
`PATCH` leaves locales absent from the payload untouched (partial edit) and
`PUT` removes them (full replace). The map key is the authoritative locale; a
`locale` in the body no longer relabels the addressed row. Expose `PUT` on
translatable resources with `extraProperties: ['standard_put' => false]` (or
set `api_platform.defaults.extra_properties.standard_put: false` globally) so
API Platform edits the managed entity; with `standard_put` on, its persist
processor copies the fresh translations collection over the managed one and
the rows cannot be matched, so the bundle rejects such a `PUT` with a
`LogicException`. `PATCH` needs no extra configuration.
7 changes: 7 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ parameters:
identifier: argument.type
path: tests/Model/TranslatableTraitTest.php
count: 1
# The owning-side `$translatable` is typed against the interface (property
# invariance forbids narrowing it to the concrete entity in the fixture),
# so phpstan-doctrine sees the association target as wider than the schema.
-
identifier: doctrine.associationType
path: tests/Fixtures/Orm/IntegrationArticleTranslation.php
count: 1
8 changes: 8 additions & 0 deletions src/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ services:
- { name: doctrine.event_listener, event: postLoad }
- { name: doctrine.event_listener, event: prePersist }

# Serializer: in-place, merge denormalization of nested translations
locastic_api_platform_translation.serializer.translatable_denormalizer:
class: Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer
tags:
- { name: serializer.normalizer, priority: 100 }

Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer: '@locastic_api_platform_translation.serializer.translatable_denormalizer'

# Filters
locastic_api_platform_translation.filter.translation_groups:
parent: 'api_platform.serializer.group_filter'
Expand Down
224 changes: 224 additions & 0 deletions src/Serializer/TranslatableItemDenormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

namespace Locastic\ApiPlatformTranslationBundle\Serializer;

use Locastic\ApiPlatformTranslationBundle\Model\TranslatableInterface;
use Locastic\ApiPlatformTranslationBundle\Model\TranslationInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
* Denormalizes the locale-keyed `translations` map of a translatable resource.
*
* 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
* itself, and:
* - populates the EXISTING translation object for a locale (stable id, in-place update),
* - creates a translation only for genuinely new locales,
* - on PATCH, leaves locales absent from the payload untouched (partial edit),
* - on PUT, removes locales absent from the payload (full replace).
*
* PUT follows HTTP replace semantics, so translatable resources must expose it with
* `extraProperties: ['standard_put' => false]` (or only offer PATCH). Otherwise API
* Platform's standard_put builds a fresh object with no existing translations to
* reconcile against, and its persist processor then copies the fresh collection over
* the managed one, so the relation cannot be persisted. A misconfigured PUT fails
* loudly here (see assertNotStandardPut()) instead of dying later in the persist layer.
*/
final class TranslatableItemDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;

private const ALREADY_CALLED = 'LOCASTIC_TRANSLATABLE_DENORMALIZER_CALLED';

/**
* @param array<string, mixed> $context
*/
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return \is_array($data)
&& isset($data['translations'])
&& \is_array($data['translations'])
&& is_a($type, TranslatableInterface::class, true)
&& empty($context[self::ALREADY_CALLED]);
}

public function getSupportedTypes(?string $format): array
{
// Only ever supports object denormalization, and the decision depends on the
// payload (must contain `translations`), so it is never cacheable.
return ['object' => false];
}

/**
* @param array<string, mixed> $context
*/
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
$this->assertNotStandardPut($type, $context);

$translationsData = $data['translations'];
unset($data['translations']);

// Let API Platform build/populate the translatable itself (minus translations).
$context[self::ALREADY_CALLED] = true;
$translatable = $this->denormalizer->denormalize($data, $type, $format, $context);

if (!$translatable instanceof TranslatableInterface) {
return $translatable;
}

$submittedLocales = [];

foreach ($translationsData as $key => $translationData) {
if (!\is_array($translationData)) {
continue;
}

// The map key is the authoritative locale; only fall back to the body
// for list-shaped payloads (numeric keys).
$locale = \is_string($key) ? $key : ($translationData['locale'] ?? null);
if (null === $locale) {
continue;
}

// A `locale` in the body must not override the resolved locale.
unset($translationData['locale']);

$submittedLocales[$locale] = true;

$target = $this->matchTranslation($translatable, $locale);
$isNew = null === $target;
if ($isNew) {
$target = $this->createTranslationFor($translatable);
if (null === $target) {
continue;
}
}
$target->setLocale($locale);

$nestedContext = $context;
$nestedContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $target;
unset($nestedContext[self::ALREADY_CALLED]);

$translation = $this->denormalizer->denormalize(
$translationData,
$target::class,
$format,
$nestedContext,
);

if ($isNew && $translation instanceof TranslationInterface) {
$translatable->addTranslation($translation);
}
}

// PUT replaces the whole resource: drop locales absent from the payload.
// PATCH is a partial edit and leaves them in place.
if ($this->isFullReplace($context)) {
foreach ($translatable->getTranslations()->toArray() as $translation) {
if (!isset($submittedLocales[(string) $translation->getLocale()])) {
$translatable->removeTranslation($translation);
}
}
}

return $translatable;
}

/**
* Matches an existing translation by locale.
*
* Iterates the collection instead of using `get($locale)` so it does not depend on
* the association being mapped with `indexBy: 'locale'`; a plain OneToMany hydrates
* as a 0-indexed list. This mirrors how the model trait resolves translations.
*
* @param TranslatableInterface<TranslationInterface> $translatable
*/
private function matchTranslation(TranslatableInterface $translatable, string $locale): ?TranslationInterface
{
foreach ($translatable->getTranslations() as $translation) {
if ($translation->getLocale() === $locale) {
return $translation;
}
}

return null;
}

/**
* Instantiates a new translation via the translatable's own factory
* (TranslatableTrait::createTranslation()), without widening its visibility.
*
* @param TranslatableInterface<TranslationInterface> $translatable
*/
private function createTranslationFor(TranslatableInterface $translatable): ?TranslationInterface
{
if (!method_exists($translatable, 'createTranslation')) {
return null;
}

$translation = (new \ReflectionMethod($translatable, 'createTranslation'))->invoke($translatable);

return $translation instanceof TranslationInterface ? $translation : null;
}

/**
* @param array<string, mixed> $context
*/
private function isFullReplace(array $context): bool
{
$operation = $this->findOperation($context);

return null !== $operation && 'PUT' === strtoupper((string) $operation->getMethod());
}

/**
* Fails loudly on a misconfigured PUT instead of letting the write die later in
* the persist layer with an obscure Doctrine error.
*
* With standard_put enabled, API Platform deserializes into a fresh object (no
* OBJECT_TO_POPULATE) and its Doctrine PersistProcessor then reflection-copies
* every property, including the translations collection, from that fresh object
* onto the managed entity. Translations therefore cannot be reconciled by locale
* at any layer; the operation must disable standard_put.
*
* @param array<string, mixed> $context
*/
private function assertNotStandardPut(string $type, array $context): void
{
if (isset($context[AbstractNormalizer::OBJECT_TO_POPULATE]) || !$this->isFullReplace($context)) {
return;
}

$operation = $this->findOperation($context);
if (null === $operation || !method_exists($operation, 'getExtraProperties')) {
return;
}

$extraProperties = $operation->getExtraProperties();
if (\is_array($extraProperties) && false !== ($extraProperties['standard_put'] ?? true)) {
throw new \LogicException(sprintf('PUT operations on translatable resources require standard_put to be disabled, otherwise translations cannot be matched to their existing rows. Set extraProperties: [\'standard_put\' => false] on the PUT operation of "%s" (or globally via api_platform.defaults.extra_properties.standard_put: false), or expose PATCH instead.', $type));
}
}

/**
* @param array<string, mixed> $context
*/
private function findOperation(array $context): ?object
{
foreach (['operation', 'root_operation'] as $key) {
$operation = $context[$key] ?? null;
if (\is_object($operation) && method_exists($operation, 'getMethod')) {
return $operation;
}
}

return null;
}
}
50 changes: 50 additions & 0 deletions tests/Fixtures/Orm/IntegrationArticle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Locastic\ApiPlatformTranslationBundle\Tests\Fixtures\Orm;

use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
use Locastic\ApiPlatformTranslationBundle\Model\TranslationInterface;

/**
* Doctrine-mapped translatable used by the serializer integration tests.
*
* The `translations` association is intentionally mapped WITHOUT `indexBy: 'locale'`
* so the collection hydrates as a plain 0-indexed list. This mirrors the minimal
* mapping a user may write and guards the denormalizer against silently depending on
* a locale-keyed collection.
*
* @extends AbstractTranslatable<IntegrationArticleTranslation>
*/
#[ORM\Entity]
class IntegrationArticle extends AbstractTranslatable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

/**
* @var Collection<string, IntegrationArticleTranslation>
*/
#[ORM\OneToMany(
targetEntity: IntegrationArticleTranslation::class,
mappedBy: 'translatable',
cascade: ['persist'],
orphanRemoval: true,
)]
protected Collection $translations;

public function getId(): ?int
{
return $this->id;
}

protected function createTranslation(): TranslationInterface
{
return new IntegrationArticleTranslation();
}
}
Loading
Loading