Skip to content

Translator

The Translator brings AI-powered translation to several places in the Sulu admin: individual text fields, full documents when copying a locale, categories, and media metadata.

Text translation

Editors can translate the content of text fields directly in the admin. The action is gated by the sulu.ai.translate security context (category Sulu AI).

Full content translation

When an editor copies content from another locale and chooses the translate option, the bundle translates the whole document in one pass instead of copying it verbatim.

Out of the box this works for:

  • Pages
  • Articles (when SuluArticleBundle is installed)
  • Snippets

on both Sulu 2.6 and Sulu 3.0.

What gets translated

The bundle walks the document's form metadata and translates:

  • All properties whose types are configured as plain-text field types (default: text_line, text_area)
  • All properties whose types are configured as HTML field types (default: text_editor) — translated with HTML handling so markup survives
  • Block content, including nested and global blocks, and image_map hotspot content
  • SEO extension data (title, description, keywords) and excerpt extension data (title, description, more)
  • Selected sub-properties of complex property types via property_type_translation_properties (default: the title of link properties and the title/description of teaser_selection items)

If your project uses custom property types that contain translatable text, add them to the configuration:

sulu_ai_platform:
    text_field_types: ['text_line', 'text_area', 'my_custom_text_type']
    html_field_types: ['text_editor', 'my_custom_editor_type']
    property_type_translation_properties:
        my_complex_type: ['items/*/label']

One request per document

Plain-text and HTML content need different tag handling, but the platform translates both in a single request. A full content translation therefore shows up as one entry — and one billed request — in the project's request list, where it is marked with the feature Content translation.

Translators that do not support content translation (for example a directly configured DeepL translator) fall back to one request per tag handling.

URL generation

After translating, the bundle regenerates the resource locator for the new locale from the translated content — using the fields tagged with sulu.rlp.part (falling back to the title) and respecting a route_schema option on the URL field. Homepage documents are excluded.

Enabled languages

The set of target languages offered to editors is a fixed list of DeepL-supported languages. Individual languages can be disabled:

sulu_ai_platform:
    translator:
        languages:
            BG: false   # hide Bulgarian as a target language

Note: The locale_map description field is not used for translations — it only provides language context for the generator experts.

Category translation

Category names and metadata can be translated into other locales from the category management in the admin.

Media metadata translation

Media titles and descriptions (alt text and caption) can be translated into the media's other locales — see Media Metadata Generator.

Translating custom entities

Full content translation is not available out of the box for custom entities (for example entities built on the content bundle). The supported path is to mirror what the bundle does for snippets — the snippet integration is deliberately the simplest reference implementation.

The pattern is the same on Sulu 2.6 and Sulu 3.0; the example below shows Sulu 2.6, on Sulu 3.0 use Sulu3FullContentTranslationSubscriber::onSnippetTranslationCopied() as the reference instead:

  1. Extend Sulu\Bundle\AiPlatformBundle\Application\Translation\FullContentTranslation\AbstractFullContentTranslationSubscriber.
  2. Subscribe to the event your entity dispatches when a translation is copied (the equivalent of SnippetTranslationCopiedEvent).
  3. Wrap the document and call handleContentTranslation() — exactly like Sulu26FullContentTranslationSubscriber::onSnippetTranslationCopied() does:
<?php

namespace App\EventSubscriber;

use App\Domain\Event\CustomEntityTranslationCopiedEvent;
use Sulu\Bundle\AiPlatformBundle\Infrastructure\Sulu\Compatibility\ContentPersister\Sulu26ContentDecorator;
use Sulu\Bundle\AiPlatformBundle\Application\Translation\FullContentTranslation\AbstractFullContentTranslationSubscriber;

final class CustomEntityFullContentTranslationSubscriber extends AbstractFullContentTranslationSubscriber
{
    public static function getSubscribedEvents(): iterable
    {
        yield CustomEntityTranslationCopiedEvent::class => 'onTranslationCopied';
    }

    public function onTranslationCopied(CustomEntityTranslationCopiedEvent $event): void
    {
        $document = $event->getDocument();

        $this->handleContentTranslation(
            'custom_entity_key',                    // form metadata key of your entity
            new Sulu26ContentDecorator($document),  // adapter around your document
            $event->getResourceLocale(),
            $event->getEventContext()['sourceLocale'] ?? null,
            $event->getEventPayload() ?? [],
            $document->getStructureType(),
        );
    }

    protected function generateUrl(array $parts, string $parentPath, string $urlFieldType, string $locale, ?string $routeSchema): string
    {
        // Only needed when your entity has a route/resource_locator field;
        // see Sulu26FullContentTranslationSubscriber for a route-generator-based implementation.
        return $parentPath . '/' . \implode('-', $parts);
    }
}
  1. Register the subscriber with the same dependencies as the built-in one:
// config/services.php
$services->set(CustomEntityFullContentTranslationSubscriber::class)
    ->args([
        service('sulu_ai_platform.translator'),
        service('sulu_admin.structure_form_metadata_loader'),
        service('sulu_admin.metadata_provider_registry'),
        service(ContentRepositoryInterface::class),
        service('request_stack'),
        service(SecurityCheckerInterface::class),
        param('sulu_ai_platform.property_type_translation_properties'),
        param('sulu_ai_platform.text_field_types'),
        param('sulu_ai_platform.html_field_types'),
    ])
    ->tag('kernel.event_subscriber');

Notes:

  • Sulu26ContentDecorator wraps PHPCR-based documents. If your custom entity is Doctrine-based (content bundle), implement the small ContentAdapterInterface for your entity instead and persist through your own repository.
  • The translation only runs when the admin request carries ?translate=true and the user holds the sulu.ai.translate permission — the abstract subscriber checks both for you.
  • Pass all three parameters shown above. Omitting text_field_types or html_field_types silently falls back to the defaults, so property types you configured project-wide would not be translated on your entity.

New optional collaborators of the abstract subscriber are injected through setters, which subclasses inherit without changing their service definition — setLogger() is one of them, so do not repurpose that name in your subclass.

Handling failures

Translation failures never abort the copy-locale request. Domain events are dispatched from a Doctrine postFlush, so an escaping exception would turn an already committed locale copy into a 500 — and because Sulu's CopyLocaleToolbarAction attaches no error handler, the editor would see a hanging dialog rather than a message.

Instead the copied locale stays in place untranslated and onTranslationFailure() is called. The default implementation logs the failure. Override it when you can report it more precisely — a subscriber that holds the domain event knows the resource key and id and can write to Sulu's activity log:

protected function onTranslationFailure(
    \Throwable $throwable,
    string $resourceType,
    string $structureType,
    string $resourceLocale,
): void {
    parent::onTranslationFailure($throwable, $resourceType, $structureType, $resourceLocale);

    // $this->currentResourceId is tracked by your own event handler
    $this->domainEventDispatcher->dispatch(
        new CustomEntityTranslationFailedEvent($this->currentResourceId, $resourceLocale),
    );
}

Anything writing to the database from here has to cope with an EntityManager that a failed flush may have closed — check isOpen() before dispatching, otherwise the failure path fails too.

Subscribing to every domain event

A subscriber that listens on DomainEvent::class — rather than on the specific event of one resource — sees the events of pages, articles and snippets as well, which this bundle already translates itself. Skip them, otherwise the resource is translated twice and the second run overwrites the first:

// %sulu_ai_platform.built_in_resource_keys%
public function __construct(
    /* ... */
    private readonly array $builtInResourceKeys = [],
) {
}

if (\in_array($event->getResourceKey(), $this->builtInResourceKeys, true)) {
    return;
}

This matters on Sulu 3.0 in particular, where pages, articles and snippets are Doctrine entities implementing ContentRichEntityInterface and are therefore picked up by any generic entity discovery.

Permission

All translation actions are gated by the sulu.ai.translate security context (category Sulu AI).