Skip to content

IBX-10684: Document translation management - #3249

Open
dabrt wants to merge 15 commits into
5.0from
translations-management
Open

IBX-10684: Document translation management#3249
dabrt wants to merge 15 commits into
5.0from
translations-management

Conversation

@dabrt

@dabrt dabrt commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
Question Answer
JIRA Ticket IBX-10684
Versions 5.0 and up
Edition all

Document translation management

Checklist

  • Text renders correctly
  • Text has been checked with vale
  • Description metadata is up to date
  • Code samples are working
  • PHP code samples have been fixed with PHP CS fixer
  • Added link to this PR in relevant JIRA ticket or code PR

@adriendupuis
adriendupuis marked this pull request as draft June 19, 2026 07:12
Comment thread docs/multisite/translations_management/extend_translations_management.md Outdated
Comment thread docs/api/event_reference/translations_management_events.md Outdated
@dabrt
dabrt requested a review from mikadamczyk June 24, 2026 07:02
@dabrt
dabrt marked this pull request as ready for review June 25, 2026 12:41
dabrt and others added 5 commits June 25, 2026 16:34
Co-Authored-By: dabrt <dabrt@users.noreply.github.com>
Co-Authored-By: Marek Nocoń <mnocon@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

code_samples/ change report

Before (on target branch)After (in current PR)

code_samples/translations_management/config/services.yaml


code_samples/translations_management/config/services.yaml

docs/multisite/translations_management/extend_translations_management.md@29:``` yaml
docs/multisite/translations_management/extend_translations_management.md@30:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 6) =]]
docs/multisite/translations_management/extend_translations_management.md@31:```

001⫶services:
002⫶ App\TranslationsManagement\MyCustomProvider:
003⫶ tags:
004⫶ - name: 'ibexa.translations_management.auto_translate.provider'
005⫶ identifier: 'my_custom_provider'
006⫶ validation_profile: 'ai_generic'

docs/multisite/translations_management/extend_translations_management.md@44:``` yaml
docs/multisite/translations_management/extend_translations_management.md@45:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@46:[[= include_code('code_samples/translations_management/config/services.yaml', 7, 10) =]]
docs/multisite/translations_management/extend_translations_management.md@47:```

001⫶services:
002⫶ App\TranslationsManagement\MyProviderValidator:
003⫶ tags:
004⫶ - name: 'ibexa.translations_management.auto_translate.provider.validator'
005⫶ profile: 'my_custom_profile'

docs/multisite/translations_management/extend_translations_management.md@79:``` yaml
docs/multisite/translations_management/extend_translations_management.md@80:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@81:[[= include_code('code_samples/translations_management/config/services.yaml', 14, 17) =]]
docs/multisite/translations_management/extend_translations_management.md@82:```

001⫶services:
002⫶ App\TranslationsManagement\ImageAltTextTransformer:
003⫶ tags:
004⫶ - name: 'ibexa.translations_management.auto_translate.field_value_transformer'
005⫶ field_type_identifier: 'ibexa_image'

docs/multisite/translations_management/extend_translations_management.md@103:``` yaml
docs/multisite/translations_management/extend_translations_management.md@104:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@105:[[= include_code('code_samples/translations_management/config/services.yaml', 18, 20) =]]
docs/multisite/translations_management/extend_translations_management.md@106:```

001⫶services:
002⫶ App\TranslationsManagement\MyCustomExclusionRule:
003⫶ tags:
004⫶ - { name: 'ibexa.translations_management.side_by_side.exclusion_rule' }

docs/multisite/translations_management/extend_translations_management.md@115:``` yaml
docs/multisite/translations_management/extend_translations_management.md@116:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@117:[[= include_code('code_samples/translations_management/config/services.yaml', 21, 26) =]]
docs/multisite/translations_management/extend_translations_management.md@118:```

001⫶services:
002⫶ app.translations_management.exclusion_rule.custom_field_types:
003⫶ class: Ibexa\TranslationsManagement\SideBySide\Service\UnsupportedFieldTypeExclusionRule
004⫶ arguments:
005⫶ $excludedFieldTypeIdentifiers: ['custom_blog_post', 'custom_landing_page']
006⫶ tags:
007⫶ - { name: 'ibexa.translations_management.side_by_side.exclusion_rule' }

docs/multisite/translations_management/extend_translations_management.md@141:``` yaml
docs/multisite/translations_management/extend_translations_management.md@142:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@143:[[= include_code('code_samples/translations_management/config/services.yaml', 27, 31) =]]
docs/multisite/translations_management/extend_translations_management.md@144:```

001⫶services:
002⫶ App\TranslationsManagement\TwigComponent\MyTranslationModalFooter:
003⫶ tags:
004⫶ - name: ibexa.twig.component
005⫶ group: 'admin-ui-content-translation-modal-footer'
006⫶ priority: 10

docs/multisite/translations_management/extend_translations_management.md@165:``` yaml
docs/multisite/translations_management/extend_translations_management.md@166:[[= include_code('code_samples/translations_management/config/services.yaml', 1, 1) =]]
docs/multisite/translations_management/extend_translations_management.md@167:[[= include_code('code_samples/translations_management/config/services.yaml', 11, 13) =]]
docs/multisite/translations_management/extend_translations_management.md@168:```

001⫶services:
002⫶ App\TranslationsManagement\MyTranslationAddExtension:
003⫶ tags:
004⫶ - { name: form.type_extension }


code_samples/translations_management/src/TranslationsManagement/ContentProxyTranslateSubscriber.php


code_samples/translations_management/src/TranslationsManagement/ContentProxyTranslateSubscriber.php

docs/multisite/translations_management/extend_translations_management.md@187:``` php hl_lines="35 36"
docs/multisite/translations_management/extend_translations_management.md@188:[[= include_code('code_samples/translations_management/src/TranslationsManagement/ContentProxyTranslateSubscriber.php') =]]
docs/multisite/translations_management/extend_translations_management.md@189:```

001⫶<?php declare(strict_types=1);
002⫶
003⫶namespace App\TranslationsManagement\EventSubscriber;
004⫶
005⫶use Ibexa\Contracts\AdminUi\Event\ContentProxyTranslateEvent;
006⫶use Symfony\Component\EventDispatcher\EventSubscriberInterface;
007⫶use Symfony\Component\HttpFoundation\RedirectResponse;
008⫶use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
009⫶
010⫶final readonly class ContentProxyTranslateSubscriber implements EventSubscriberInterface
011⫶{
012⫶ public function __construct(
013⫶ private UrlGeneratorInterface $urlGenerator,
014⫶ ) {
015⫶ }
016⫶
017⫶ public static function getSubscribedEvents(): array
018⫶ {
019⫶ return [
020⫶ ContentProxyTranslateEvent::class => ['onProxyTranslate', 200],
021⫶ ];
022⫶ }
023⫶
024⫶ public function onProxyTranslate(ContentProxyTranslateEvent $event): void
025⫶ {
026⫶ // Read the translation context:
027⫶ $event->getContentId();
028⫶ $event->getFromLanguageCode(); // ?string — null when no source language exists
029⫶ $event->getToLanguageCode();
030⫶ $event->getLocationId(); // ?int — null when no location context is available
031⫶
032⫶ $url = $this->urlGenerator->generate('your_custom_route', [
033⫶ 'contentId' => $event->getContentId(),
034⫶ ]);
035❇️
036❇️ $event->setResponse(new RedirectResponse($url));
037⫶ $event->stopPropagation();
038⫶ }
039⫶}


code_samples/translations_management/src/TranslationsManagement/ImageAltTextTransformer.php


code_samples/translations_management/src/TranslationsManagement/ImageAltTextTransformer.php

docs/multisite/translations_management/extend_translations_management.md@71:``` php hl_lines="19 24"
docs/multisite/translations_management/extend_translations_management.md@72:[[= include_code('code_samples/translations_management/src/TranslationsManagement/ImageAltTextTransformer.php') =]]
docs/multisite/translations_management/extend_translations_management.md@73:```

001⫶<?php
002⫶
003⫶declare(strict_types=1);
004⫶
005⫶namespace App\TranslationsManagement;
006⫶
007⫶use Ibexa\Contracts\Core\FieldType\Value;
008⫶use Ibexa\Contracts\Core\Repository\Values\Content\Field;
009⫶use Ibexa\Contracts\TranslationsManagement\AutoTranslate\Transformer\Field\EncodedFieldValue;
010⫶use Ibexa\Contracts\TranslationsManagement\AutoTranslate\Transformer\Field\FieldValueTransformerInterface;
011⫶
012⫶final class ImageAltTextTransformer implements FieldValueTransformerInterface
013⫶{
014⫶ public function getFieldTypeIdentifier(): string
015⫶ {
016⫶ return 'ibexa_image';
017⫶ }
018⫶
019❇️ public function encode(Field $field): EncodedFieldValue
020⫶ {
021⫶ return new EncodedFieldValue($field->getValue()->alternativeText ?? '');
022⫶ }
023⫶
024❇️ /**
025⫶ * @param array<string, mixed> $metadata
026⫶ */
027⫶ public function decode(string $value, mixed $previousFieldValue, array $metadata): Value
028⫶ {
029⫶ $previousFieldValue->alternativeText = $value;
030⫶
031⫶ return $previousFieldValue;
032⫶ }
033⫶}


code_samples/translations_management/src/TranslationsManagement/MyApiClient.php


code_samples/translations_management/src/TranslationsManagement/MyCustomExclusionRule.php


code_samples/translations_management/src/TranslationsManagement/MyApiClient.php


code_samples/translations_management/src/TranslationsManagement/MyCustomExclusionRule.php

docs/multisite/translations_management/extend_translations_management.md@97:``` php
docs/multisite/translations_management/extend_translations_management.md@98:[[= include_code('code_samples/translations_management/src/TranslationsManagement/MyCustomExclusionRule.php') =]]
docs/multisite/translations_management/extend_translations_management.md@99:```

001⫶<?php
002⫶
003⫶declare(strict_types=1);
004⫶
005⫶namespace App\TranslationsManagement;
006⫶
007⫶use Ibexa\Contracts\Core\Repository\Values\Content\ContentInfo;
008⫶use Ibexa\Contracts\TranslationsManagement\SideBySide\Service\SideBySideExclusionRuleInterface;
009⫶
010⫶final class MyCustomExclusionRule implements SideBySideExclusionRuleInterface
011⫶{
012⫶ public function isExcluded(ContentInfo $contentInfo): bool
013⫶ {
014⫶ return $contentInfo->getContentType()->identifier === 'my_excluded_type';
015⫶ }
016⫶}


code_samples/translations_management/src/TranslationsManagement/MyCustomProvider.php


code_samples/translations_management/src/TranslationsManagement/MyCustomProvider.php

docs/multisite/translations_management/extend_translations_management.md@22:``` php hl_lines="36-49"
docs/multisite/translations_management/extend_translations_management.md@23:[[= include_code('code_samples/translations_management/src/TranslationsManagement/MyCustomProvider.php') =]]
docs/multisite/translations_management/extend_translations_management.md@24:```

001⫶<?php
002⫶
003⫶declare(strict_types=1);
004⫶
005⫶namespace App\TranslationsManagement;
006⫶
007⫶use Ibexa\Contracts\TranslationsManagement\AutoTranslate\Provider\TranslationProviderInterface;
008⫶use Ibexa\Contracts\TranslationsManagement\AutoTranslate\TranslationDataInterface;
009⫶
010⫶final readonly class MyCustomProvider implements TranslationProviderInterface
011⫶{
012⫶ /**
013⫶ * Replace MyApiClient with your HTTP client, SDK wrapper, or any service
014⫶ * that communicates with the external translation API.
015⫶ */
016⫶ public function __construct(
017⫶ private MyApiClient $apiClient,
018⫶ ) {
019⫶ }
020⫶
021⫶ public function getIdentifier(): string
022⫶ {
023⫶ return 'my_custom_provider';
024⫶ }
025⫶
026⫶ public function getName(): string
027⫶ {
028⫶ return 'My Translation Service';
029⫶ }
030⫶
031⫶ public function getVendorName(): string
032⫶ {
033⫶ return 'My Company Ltd';
034⫶ }
035⫶
036❇️ public function translate(TranslationDataInterface $translationData): string
037❇️ {
038❇️ return $this->apiClient->translate(
039❇️ $translationData->getText(),
040❇️ $translationData->getSourceLanguage(),
041❇️ $translationData->getTargetLanguage()
042❇️ );
043❇️ }
044❇️
045❇️ /** @return array<string> */
046❇️ public function getSupportedLanguageCodes(): array
047❇️ {
048❇️ return ['en_GB', 'de_DE', 'fr_FR'];
049❇️ }
050⫶}


code_samples/translations_management/src/TranslationsManagement/MyTranslationAddExtension.php


code_samples/translations_management/src/TranslationsManagement/MyTranslationAddExtension.php

docs/multisite/translations_management/extend_translations_management.md@159:``` php
docs/multisite/translations_management/extend_translations_management.md@160:[[= include_code('code_samples/translations_management/src/TranslationsManagement/MyTranslationAddExtension.php') =]]
docs/multisite/translations_management/extend_translations_management.md@161:```

001⫶<?php
002⫶
003⫶declare(strict_types=1);
004⫶
005⫶namespace App\TranslationsManagement;
006⫶
007⫶use Ibexa\AdminUi\Form\Type\Content\Translation\TranslationAddType;
008⫶use Symfony\Component\Form\AbstractTypeExtension;
009⫶use Symfony\Component\Form\FormBuilderInterface;
010⫶
011⫶final class MyTranslationAddExtension extends AbstractTypeExtension
012⫶{
013⫶ public static function getExtendedTypes(): iterable
014⫶ {
015⫶ return [TranslationAddType::class];
016⫶ }
017⫶
018⫶ public function buildForm(FormBuilderInterface $builder, array $options): void
019⫶ {
020⫶ $builder->add('my_custom_field'/* ... */);
021⫶ }
022⫶}


code_samples/translations_management/src/TranslationsManagement/TranslationPairManager.php


code_samples/translations_management/src/TranslationsManagement/TranslationPairManager.php

docs/multisite/translations_management/configure_translations_management.md@184:``` php hl_lines="2"
docs/multisite/translations_management/configure_translations_management.md@185:[[= include_code('code_samples/translations_management/src/TranslationsManagement/TranslationPairManager.php', 14, 35) =]]
docs/multisite/translations_management/configure_translations_management.md@186:```

001⫶ public function __construct(
002❇️ private LanguagePairServiceInterface $languagePairService,
003⫶ private LanguageService $languageService,
004⫶ ) {
005⫶ }
006⫶
007⫶ public function addPair(
008⫶ string $sourceLanguageCode,
009⫶ string $targetLanguageCode,
010⫶ TranslationProviderInterface $provider,
011⫶ bool $replaceExisting = false,
012⫶ ): LanguagePairInterface {
013⫶ $sourceLanguage = $this->languageService->loadLanguage($sourceLanguageCode);
014⫶ $targetLanguage = $this->languageService->loadLanguage($targetLanguageCode);
015⫶
016⫶ return $this->languagePairService->createLanguagePair(
017⫶ $sourceLanguage,
018⫶ $targetLanguage,
019⫶ $provider,
020⫶ $replaceExisting,
021⫶ );
022⫶ }

Download colorized diff

@dabrt
dabrt requested a review from AnnaKaih July 22, 2026 12:53

| Event | Dispatched by | Dispatched when | Properties |
|---|---|---|----|
| [`BeforeTranslateEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-TranslationsManagement-AutoTranslate-Event-BeforeTranslateEvent.html) | `TranslationService` | Before a translation request is sent to the provider | `TranslationProviderInterface $provider`</br>`string $text`</br>`string $sourceLanguage`</br>`string $targetLanguage` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| [`BeforeTranslateEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-TranslationsManagement-AutoTranslate-Event-BeforeTranslateEvent.html) | `TranslationService` | Before a translation request is sent to the provider | `TranslationProviderInterface $provider`</br>`string $text`</br>`string $sourceLanguage`</br>`string $targetLanguage` |
| [`BeforeTranslateEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-TranslationsManagement-AutoTranslate-Event-BeforeTranslateEvent.html) | `EventDispatchingProviderTranslator` | Before a translation request is sent to the provider | `TranslationProviderInterface $provider`</br>`string $text`</br>`string $sourceLanguage`</br>`string $targetLanguage` |

- Draft conflict warning

When an user opens the translation modal and selects a target language which already has a draft translation, a warning appears in the modal.
The warning is shown or hidden dynamically by `side-by-side-translation-modal-warning.js` when the user changes the target language selection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The warning is shown or hidden dynamically by `side-by-side-translation-modal-warning.js` when the user changes the target language selection.
The warning is shown or hidden dynamically by `add.translation.modal.warning.js` when the user changes the target language selection.

tags:
- name: 'ibexa.translations_management.auto_translate.provider'
identifier: 'my_custom_provider'
validation_profile: 'ai_generic'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation profile should be based on registered Validators and their profile identifier.

Suggested change
validation_profile: 'ai_generic'
validation_profile: 'my_custom_profile'

Comment on lines +179 to +198
## Manage language pairs programmatically

To manage language pairs programmatically, create a service class and inject `LanguagePairServiceInterface` into its constructor.
Symfony autowires it automatically so no manual service configuration is needed.

``` php hl_lines="2"
[[= include_code('code_samples/translations_management/src/TranslationsManagement/TranslationPairManager.php', 14, 35) =]]
```

The service exposes the following methods:

| Method | Description |
|---|---|
| `createLanguagePair()` | Create a new language pair. Pass `true` as the fourth argument to overwrite an existing pair with the same source and target. |
| `updateLanguagePair()` | Update an existing language pair by ID. |
| `syncLanguagePairsForSourceAndProvider()` | Synchronize all target languages for a given source language and provider. |
| `loadLanguagePairs()` | Load all configured language pairs. |
| `deleteLanguagePairById()` | Delete a language pair by ID. |
| `deleteLanguagePairsForProvider()` | Delete all language pairs associated with a given provider. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this should be dropped because LanguagePairServiceInterface is not a contract and should not be exposed as example to manage Language Pairs outside of the translations management bundle.


When an editor clicks the **Copy all from source** action, all translatable field values are copied from the source to target column.
It's a single server-side operation handled by `SideBySideFieldCopyService::copyAllFields()` after which the view is reloaded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider to add new section: Review of automatic translations or something similar.

When a draft is created with automatic translation, it's marked for review; editors accept or reject the result. - This could point to example from user documentation with snack back contains actions.


The [side-by-side translation view]([[= user_doc =]]/content_management/translate_content/#side-by-side-translation-view) is a two-column content editing interface where the source column is read-only and the target column is an editable form.

Content types that contain the `ibexa_landing_page` or `ibexa_form` fields are not supported, and editors can open them in the standard single-language editor only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should to clarify that excluding a content type from the side-by-side view does not make it untranslatable. Landing Page and other excluded content types can still be auto-translated through the standard translation flow and the CLI command - only the side-by-side editing interface is unavailable for them.


To exclude additional content types, for example, content types whose fields render incorrectly in the side-by-side layout, implement [`SideBySideExclusionRuleInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-TranslationsManagement-SideBySide-Service-SideBySideExclusionRuleInterface.html).
The `isExcluded()` method receives a [`ContentInfo`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Core-Repository-Values-Content-ContentInfo.html) object and returns `true` if the content item should be excluded.
Classes that implement this interface are automatically tagged with `autoconfigure`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Classes that implement this interface are automatically tagged with `autoconfigure`:
Register the rule with the `ibexa.translations_management.side_by_side.exclusion_rule` tag. This interface is not registered for Symfony autoconfiguration, so the tag is required.

[[= include_code('code_samples/translations_management/src/TranslationsManagement/MyCustomExclusionRule.php') =]]
```

If `autoconfigure` is not available, register the tag explicitly:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
If `autoconfigure` is not available, register the tag explicitly:

`ContentProxyTranslateEvent` is marked `@internal` in `ibexa/admin-ui`.
While it functions as an extension point in practice, its name and signature may change.
It may even be removed entirely without a deprecation notice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider to be more precise here and clarify that when the package's Subscriber fails to create the auto-translated draft in this flow (for example, the provider is unreachable), it catches the error, shows an error notification, and redirects to the content view. It does not surface a full error page.

In all cases, you follow the same pattern: implement an interface or extend a base class, then register the service with a service tag.
The package discovers and registers tagged services automatically.

## Add custom translation provider

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should distinguish regular translation provider from Ai translation provider. There are two different interfaces which allows to determine which provider will be added:

  • TranslationProviderInterface
  • AiTranslationProviderInterface

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants