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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`locale_resolution` (ordered locale sources, `query_param` and
`accept_language`; each can be removed or reordered) (#85)
- FQCN alias for the `Translator` service, so it can be autowired by type (#85)
- `auto_create_translations` option: set to `false` to stop `getTranslation()`
from creating and attaching a translation for a missing locale (which, with
`cascade: persist`, lets a read insert empty rows); it returns a detached,
never-persisted translation instead (#55, #34)
- `getOrCreateTranslation()` on `TranslatableTrait`: the explicit write target
for virtual setters; creates and attaches the missing translation for the
exact locale, without locale fallback, regardless of
`auto_create_translations`

### Changed
- Modern bundle layout: the bundle class extends `AbstractBundle` and services
Expand All @@ -28,6 +36,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Deprecated
- `ApiPlatformTranslationExtension`, to be removed in 3.0; the bundle registers
its extension itself through `AbstractBundle` (#85)
- Not setting `auto_create_translations` explicitly; its default (`true`)
flips to `false` in 3.0 (see UPGRADE-2.1.md)

## [2.0.1] - 2026-07-07

Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ api_platform_translation:
# null (the default) inherits framework.default_locale.
fallback_locale: null

# true (the legacy behavior): reading a translation that does not exist
# creates an empty one and attaches it to the entity, and with the
# documented cascade persist mapping a plain read can then insert empty
# rows into the database. false: reads never write, a missing translation
# just shows empty fields. Setters work either way, they write through
# getOrCreateTranslation() (see below). The default flips to false in
# 3.0; leaving the option unset is deprecated since 2.1.
auto_create_translations: true

# Ordered sources the request locale is resolved from; the first source
# producing a locale wins. Remove a source to disable it.
locale_resolution:
Expand All @@ -68,7 +77,7 @@ Implementation:
- Extend your resource with `Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable`
- Add a `createTranslation()` method which returns a new object of the translation entity
- Add a `translations` property: a `OneToMany` to the translation entity, indexed by locale, with the `translations` serialization group
- Add virtual fields for all translatable fields; their getters and setters delegate to the translation object
- Add virtual fields for all translatable fields; getters delegate to `getTranslation()` (current locale, with fallback), setters to `getOrCreateTranslation()` (exact locale, created and attached when missing)

Example:
```php
Expand Down Expand Up @@ -133,7 +142,7 @@ class Article extends AbstractTranslatable

public function setTitle(string $title): void
{
$this->getTranslation()->setTitle($title);
$this->getOrCreateTranslation()->setTitle($title);
}

protected function createTranslation(): TranslationInterface
Expand Down
31 changes: 28 additions & 3 deletions UPGRADE-2.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,31 @@

## Deprecations

1. `Locastic\ApiPlatformTranslationBundle\DependencyInjection\ApiPlatformTranslationExtension`
1. Not setting the `api_platform_translation.auto_create_translations` option
explicitly is deprecated. It defaults to `true` (the 2.0 behavior:
`getTranslation()` creates and attaches a translation when the requested
locale is missing, which `cascade: persist` then inserts on the next flush,
so a read can write empty rows). In 3.0 the default flips to `false`, where
`getTranslation()` returns a detached, never-persisted translation instead.
Set the option explicitly to silence the deprecation and, if you opt into
`false`, change virtual setters to write through the new
`getOrCreateTranslation()` method:

```php
public function setTitle(string $title): void
{
$this->getOrCreateTranslation()->setTitle($title);
}
```

`getOrCreateTranslation()` always creates and attaches the missing
translation for the exact locale (it never falls back to another locale, so
it cannot overwrite the fallback translation's content), regardless of the
`auto_create_translations` setting. Getters keep using `getTranslation()`.
Writes through the API (the `translations` payload) are unaffected by the
option: the denormalizer creates translations explicitly.

2. `Locastic\ApiPlatformTranslationBundle\DependencyInjection\ApiPlatformTranslationExtension`
is deprecated and will be removed in 3.0. The bundle now extends
`AbstractBundle` and registers its extension itself. The class keeps working
if you instantiate it directly, but stop referencing it: registering the
Expand All @@ -23,5 +47,6 @@ These only affect you if you referenced bundle files by path:
## New configuration

The bundle now has a configuration tree under `api_platform_translation`
(`enabled_locales`, `fallback_locale`, `locale_resolution`). All defaults
preserve 2.0 behavior; see the README "Configuration" section.
(`enabled_locales`, `fallback_locale`, `auto_create_translations`,
`locale_resolution`). All defaults preserve 2.0 behavior; see the README
"Configuration" section.
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
->args([
service('locastic_api_platform_translation.translation.translator'),
param('locastic_api_platform_translation.fallback_locale'),
param('locastic_api_platform_translation.auto_create_translations'),
])
->tag('doctrine.event_listener', ['event' => 'postLoad'])
->tag('doctrine.event_listener', ['event' => 'prePersist']);
Expand Down
3 changes: 2 additions & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"rules": [
"A translatable resource extends AbstractTranslatable, implements createTranslation(), and maps translations as OneToMany with indexBy: 'locale', cascade: ['persist'], orphanRemoval: true.",
"The translation entity extends AbstractTranslation and holds all translatable fields plus a writable locale field.",
"Virtual getters and setters on the translatable delegate to getTranslation(), which resolves the current locale and falls back to the fallback locale.",
"Virtual getters on the translatable delegate to getTranslation(), which resolves the current locale and falls back to the fallback locale; virtual setters delegate to getOrCreateTranslation(), which targets the exact locale and creates the translation when missing.",
"Set auto_create_translations: false so reads never write to the database; the default (true) keeps the legacy behavior where getTranslation() attaches an empty translation for a missing locale, which cascade persist can insert on the next flush. The default flips to false in 3.0.",
"Put the 'translations' serialization group on the translations collection, on every field of the translation entity, and in the normalizationContext of POST, PUT, and PATCH operations so writes return all translation objects.",
"In write payloads, translations is an object keyed by locale, and each entry must repeat its 'locale' field: {\"translations\": {\"de\": {\"title\": \"...\", \"locale\": \"de\"}}}.",
"Prefer PATCH (application/merge-patch+json) for editing translations: it updates only the submitted locales. PUT is a full replace and removes locales absent from the payload.",
Expand Down
15 changes: 14 additions & 1 deletion src/ApiPlatformTranslationBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public function configure(DefinitionConfigurator $definition): void
->info('Locale stamped as the fallback on loaded and persisted translatables. Defaults to kernel.default_locale.')
->defaultNull()
->end()
->booleanNode('auto_create_translations')
->info('Whether reading a translation that does not exist creates an empty one and attaches it to the entity (with cascade persist, a read can then insert empty rows). Set false so reads never write: missing translations just show empty fields. Defaults to true; the default flips to false in 3.0.')
->defaultNull()
->end()
->arrayNode('locale_resolution')
->info('Ordered list of sources the request locale is resolved from; the first source producing a locale wins. Remove a source to disable it.')
->performNoDeepMerging()
Expand All @@ -43,13 +47,22 @@ public function configure(DefinitionConfigurator $definition): void
}

/**
* @param array{enabled_locales: list<string>, fallback_locale: ?string, locale_resolution: list<string>} $config
* @param array{enabled_locales: list<string>, fallback_locale: ?string, auto_create_translations: ?bool, locale_resolution: list<string>} $config
*/
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
if (null === $config['auto_create_translations']) {
trigger_deprecation(
'locastic/api-platform-translation-bundle',
'2.1',
'Not setting the "api_platform_translation.auto_create_translations" option is deprecated, its default will change from true to false in 3.0. Set it explicitly to keep the current behavior.',
);
}

$container->parameters()
->set('locastic_api_platform_translation.enabled_locales', $config['enabled_locales'] ?: '%kernel.enabled_locales%')
->set('locastic_api_platform_translation.fallback_locale', $config['fallback_locale'] ?? '%kernel.default_locale%')
->set('locastic_api_platform_translation.auto_create_translations', $config['auto_create_translations'] ?? true)
->set('locastic_api_platform_translation.locale_resolution', $config['locale_resolution']);

$container->import('../config/services.php');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function load(array $configs, ContainerBuilder $container): void
{
$container->setParameter('locastic_api_platform_translation.enabled_locales', '%kernel.enabled_locales%');
$container->setParameter('locastic_api_platform_translation.fallback_locale', '%kernel.default_locale%');
$container->setParameter('locastic_api_platform_translation.auto_create_translations', true);
$container->setParameter('locastic_api_platform_translation.locale_resolution', ['query_param', 'accept_language']);

$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'config'));
Expand Down
6 changes: 6 additions & 0 deletions src/EventListener/AssignLocaleListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class AssignLocaleListener
public function __construct(
private Translator $translator,
private string $defaultLocale = 'en',
private bool $autoCreateTranslations = true,
) {
}

Expand Down Expand Up @@ -47,5 +48,10 @@ private function assignLocale(LifecycleEventArgs $args): void
$localeCode = $this->translator->loadCurrentLocale();
$object->setCurrentLocale($localeCode);
$object->setFallbackLocale($this->defaultLocale);

// Not on TranslatableInterface: adding a method there would break implementors not using the trait.
if (method_exists($object, 'setAutoCreateTranslations')) {
$object->setAutoCreateTranslations($this->autoCreateTranslations);
}
}
}
39 changes: 39 additions & 0 deletions src/Model/TranslatableTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ trait TranslatableTrait
protected array $translationsCache = [];
protected ?string $currentLocale = null;
protected ?string $fallbackLocale = null;
protected bool $autoCreateTranslations = true;

/**
* @codeCoverageIgnore
Expand Down Expand Up @@ -76,13 +77,46 @@ public function getTranslation(?string $locale = null): TranslationInterface
$translation = $this->createTranslation();
$translation->setLocale($locale);

if (!$this->autoCreateTranslations) {
return $translation;
}

$this->addTranslation($translation);

$this->translationsCache[$locale] = $translation;

return $translation;
}

/**
* @psalm-return T
*
* @throws \RuntimeException
*/
public function getOrCreateTranslation(?string $locale = null): TranslationInterface
{
if ($this instanceof Proxy && !$this->__isInitialized()) {
$this->__load();
}

$locale = $locale ?: $this->currentLocale;
if (null === $locale) {
throw new \RuntimeException('No locale has been set and current locale is undefined.');
}

$translation = $this->matchTranslation($locale);
if (null !== $translation) {
return $translation;
}

$translation = $this->createTranslation();
$translation->setLocale($locale);

$this->addTranslation($translation);

return $translation;
}

/**
* @psalm-return T|null
*/
Expand Down Expand Up @@ -186,6 +220,11 @@ public function setFallbackLocale(?string $fallbackLocale): void
$this->fallbackLocale = $fallbackLocale;
}

public function setAutoCreateTranslations(bool $autoCreateTranslations): void
{
$this->autoCreateTranslations = $autoCreateTranslations;
}

/**
* Create resource translation model.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function testDeprecatedExtensionStillRegistersServices(): void
$this->assertTrue($container->hasDefinition('locastic_api_platform_translation.serializer.translatable_denormalizer'));
$this->assertTrue($container->hasDefinition('locastic_api_platform_translation.filter.translation_groups'));
$this->assertSame('%kernel.enabled_locales%', $container->getParameter('locastic_api_platform_translation.enabled_locales'));
$this->assertTrue($container->getParameter('locastic_api_platform_translation.auto_create_translations'));
$this->assertSame('api_platform_translation', $extension->getAlias());
}
}
34 changes: 34 additions & 0 deletions tests/EventListener/AssignLocaleListenerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ public function testPrePersistNonTranslatableObjects(object $object): void
$assignLocaleSubscriber->postLoad($args);
}

/**
* @test postLoad
*/
public function testPostLoadStampsAutoCreateTranslations(): void
{
$translatable = new DummyTranslatable();
$args = $this->createMock(LifecycleEventArgs::class);
$this->getObjectInfo($args, $translatable);
$this->loadCurrentLocale();

$assignLocaleSubscriber = new AssignLocaleListener($this->translator, 'en', false);
$assignLocaleSubscriber->postLoad($args);

$translatable->getTranslation('fr');
$this->assertCount(0, $translatable->getTranslations(), 'With auto-create disabled, reading a missing locale must not attach a translation.');
}

/**
* @test postLoad
*/
public function testPostLoadKeepsAutoCreateTranslationsByDefault(): void
{
$translatable = new DummyTranslatable();
$args = $this->createMock(LifecycleEventArgs::class);
$this->getObjectInfo($args, $translatable);
$this->loadCurrentLocale();

$assignLocaleSubscriber = new AssignLocaleListener($this->translator);
$assignLocaleSubscriber->postLoad($args);

$translatable->getTranslation('fr');
$this->assertCount(1, $translatable->getTranslations());
}

private function getObjectInfo(\PHPUnit\Framework\MockObject\MockObject $args, object $object): void
{
$args
Expand Down
12 changes: 12 additions & 0 deletions tests/Functional/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,24 @@ public function testDefaultsWithBareFramework(): void

$this->assertSame([], $container->getParameter('locastic_api_platform_translation.enabled_locales'));
$this->assertSame('en', $container->getParameter('locastic_api_platform_translation.fallback_locale'));
$this->assertTrue($container->getParameter('locastic_api_platform_translation.auto_create_translations'));
$this->assertSame(
[Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_ACCEPT_LANGUAGE],
$container->getParameter('locastic_api_platform_translation.locale_resolution')
);
}

public function testAutoCreateTranslationsCanBeDisabled(): void
{
self::$translationConfig = [
'auto_create_translations' => false,
];
self::bootKernel();
$container = static::getContainer();

$this->assertFalse($container->getParameter('locastic_api_platform_translation.auto_create_translations'));
}

public function testDefaultsInheritFrameworkLocales(): void
{
self::$frameworkConfig = [
Expand Down
Loading
Loading