Skip to content

Translation (multilingual content) ​

Multilingual content areas live in a satellite package, klehm/content-blocks-i18n, so the core stays single-language by default. Installing it changes nothing on its own: with no translation rows and no target locale resolved, every block renders its own data exactly as before.

bash
composer require klehm/content-blocks-i18n

The model in one sentence ​

One shared layout, per-locale field values. Sections, columns, block order and styling are language-agnostic and stay shared; only fields a block type tagged as translatable are swapped per locale.

That is a deliberate constraint, not a limitation waiting to be lifted. It means a translated page cannot drift structurally from its source — move a section and every language moves with it — and it means adding a language costs text, not a second page to maintain.

The translation workbench

What this package does not do ​

Worth knowing before you choose it, since each follows from the model rather than from a missing feature:

  • No layout per language. Sections, columns, order and styling are shared. A block cannot be hidden in one language, and a block added to the page appears in every language at once, in the source text until translated.
  • Only tagged fields change. Text, links, and the kit's image and video files are tagged; enums, sizes and colours are not. A file stays shared until an editor replaces it for a language (Localized images and videos).
  • No implicit fallback between locales. An untranslated field shows the source text; fr_CA reads fr first only if you configure it (Fallback chain).
  • Not your site's i18n. It translates block content. Routes per locale and your templates' strings stay with your app; the rendering locale comes from your request through RenderLocaleResolverInterface.
  • No machine translation engine. The seam is there; the engine, and where a page's text is sent, is your choice.

Configuration ​

yaml
# config/packages/content_blocks_i18n.yaml
content_blocks_i18n:
    source_locale: en
    locales:
        - fr
        - { code: de, label: 'Deutsch' }
        - es
    fallbacks: {}            # default; see "Fallback chain"
    workbench:
        public_links: true   # default; see "Links to each language"

The source locale is not a target. A block's data is the source text: there is no row for it, nothing to fall back to, and a progress percentage on it would be meaningless.

You also need the cb_block_translation table — the bundle maps the entity, the migration is yours. Copy the sandbox's.

Locales from the host ​

When your locales already live somewhere else, typically in the database as in Sylius, don't copy them into locales. Alias TargetLocalesProviderInterface instead; no configuration line is needed:

php
use ContentBlocks\I18n\Locale\TargetLocalesProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(TargetLocalesProviderInterface::class)]
final class ChannelTargetLocales implements TargetLocalesProviderInterface
{
    public function __construct(private readonly LocaleRepository $locales) {}

    public function getTargetLocales(): array
    {
        return array_map(
            static fn (Locale $locale): string => $locale->getCode(),
            $this->locales->findBy(['enabled' => true], ['position' => 'ASC']),
        );
    }
}
  • The list may include the source locale. It is dropped, as it is from the config.
  • The order you return is the order of the workbench and the commands.
  • The codes under locales are ignored once you alias the provider, but their labels still apply. Keep - { code: de, label: 'Deutsch' } entries to name a language your provider returns. Without a label, ext-intl names it.
  • It is called once per container. Under PHP-FPM that means once per request, so keep it to one cheap query. Under a worker runtime it is called once for the worker's whole lifetime: a locale you add appears after the workers restart.

Tagging a field ​

Use the core's cb_translatable form option — the convention frozen at 1.0, so your blocks and the kit's tag identically:

php
$builder->add('title', TextType::class, [
    'label' => 'Title',
    'cb_translatable' => true,
]);

Tag what legitimately differs between languages: prose, labels, alt text, captions, and link targets (a localized site points at /fr/contact). Leave out enums, colors, sizes and IDs. A stored value for an untagged field is ignored at render, so the tags remain the authority even after a release changes them.

Rendering ​

Nothing in your templates changes. Route with a locale and Symfony's locale listener does the rest:

php
#[Route('/{_locale}/page/{id}', requirements: ['_locale' => 'fr|de|es'])]
public function show(int $id): Response { /* no mention of translation */ }

To pin a locale with no request in play — a sitemap job, a transactional email — pass one through the render context:

php
$renderer->render($area, RenderContext::forPublic('de'));

Fallback is per field, not per block. An untranslated field keeps its source text while its neighbours render translated. The alternative makes a half-translated page look broken rather than incomplete, and makes incremental translation pointless since nothing shows until everything is done.

Fallback chain ​

By default an untranslated field shows the source text, whatever the locale. A site with both fr and fr_CA usually wants the Canadian page to read the French translation first:

yaml
content_blocks_i18n:
    source_locale: en
    locales: [fr, fr_CA, pt_PT, pt_BR, es]
    fallbacks:
        fr_CA: fr               # one locale
        pt_BR: [pt_PT, es]      # or an ordered list
  • Per field, like the rest. For each field, the first locale of the chain with a value wins, and the source comes last. Tab titles and localized images follow the same chain.
  • Each chain is complete as written. pt_BR: [pt_PT] plus pt_PT: [es] does not make pt_BR read es: list it if you want it.
  • Only what a locale renders changes. The workbench and content-blocks:i18n:status still count an untranslated fr_CA field as missing, since the text on the page is fr's, not a translation into fr_CA. The public page reads the fallback's published value, the preview its draft.
  • Checked at boot. The source locale cannot be listed (it ends every chain already), nor a locale as its own fallback. A fallback that is not a configured target, for instance one your TargetLocalesProviderInterface stopped returning, is skipped.

It is off by default because turning it on changes pages: a host that has both fr and fr_CA would suddenly show French text where it showed the source.

Fallbacks from the host ​

When your locales come from a provider, a map in YAML goes stale as soon as a language is enabled. Alias LocaleFallbacksProviderInterface instead, for example to make every regional locale read its language first:

php
use ContentBlocks\I18n\Locale\LocaleFallbacksProviderInterface;
use ContentBlocks\I18n\Locale\TargetLocalesProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(LocaleFallbacksProviderInterface::class)]
final class ParentLocaleFallbacks implements LocaleFallbacksProviderInterface
{
    public function __construct(private readonly TargetLocalesProviderInterface $targets) {}

    public function getFallbacks(): array
    {
        $targets = $this->targets->getTargetLocales();
        $chains = [];

        foreach ($targets as $locale) {
            $parent = strtok($locale, '_-');   // fr_CA → fr
            if ($parent !== $locale && in_array($parent, $targets, true)) {
                $chains[$locale] = [$parent];
            }
        }

        return $chains;
    }
}

It works like the locale provider:

  • It replaces fallbacks. Once you alias it, the config map is ignored.
  • What it returns is filtered, not checked at boot. The source, a locale listed as its own fallback, and a code that is not a target are dropped, the same as for the config.
  • It is called once per container: once per request under PHP-FPM, once for a worker's lifetime under a worker runtime.

Localized images and videos ​

A picture with words in it, or a video recorded in one language, can be replaced per language. The kit tags the files of image, gallery, card and video (file and poster). A block of yours does the same by tagging its upload field:

php
$builder->add('src', ImageUploadType::class, ['cb_translatable' => true]);

A file field behaves differently from a text field in four ways:

  • Shared is not missing. Most pictures are the same in every language. A file with no localized version shows Same file as the source and does not count against the page's progress. Once replaced, it is translated, and it turns outdated when the source file changes.
  • Never sent to an engine. Machine translation skips file fields, whatever the provider.
  • Uploaded, not typed. The workbench row shows both files, with a button and a drop zone. Uploads go through the core's upload endpoint, with its size and MIME limits and its canEdit() check. ⨯ goes back to the source's file.
  • A path, checked. A value is a scheme-less path or an http(s) URL. Anything else is refused with invalid_media, and a blank value clears.

Stored like any translation, a localized file is published with the page, kept by the asset sweep, and embedded in an export. VideoUploadType and ImageUploadType (or a type whose parent chain reaches one) are what mark a field as a file.

Draft, published, and publishing one language ​

Translations are written to the draft and ride the area's existing Publish and Discard. A translation typed on a published page is a pending change like any other: the builder's Publish button lights up for it.

This is the rule that prevents the failure the feature exists to avoid: a French heading live on the public site describing an English heading that is still an unpublished draft. Source and translations go live together, or not at all.

The workbench also has Publish EN (and Discard changes) for its own language, so a translator does not need the builder. It publishes that language only, and it is off while the page has unpublished changes of its own: Publish always puts the page's draft live too, so publishing English there would publish an editor's unfinished work with it. The button then says why, and the builder's Publish is the way, translations included. It goes through the same publisher as the builder's, so publish events fire and a listener can refuse it.

Three states ​

StateMeaningRenders as
MissingNo value storedthe source text
TranslatedStored, source unchanged sincethe translation
OutdatedStored, but the source changed afterwardsthe translation, flagged

Outdated is tracked separately because it is the state that quietly rots. "Translated vs not" is easy to compute and useless: the field that costs money is the one that was translated and whose source has since been rewritten, because nothing about the page looks wrong — the German is there, it is simply describing last month's offer.

It is detected by storing a digest of the source text beside the translation. Nothing else — no timestamps, no revision numbers — so editing an unrelated field cannot perturb it. An editor who judges a translation still correct clicks "still current" and the digest is re-stamped without retyping anything. A staleness flag that can only be cleared by redoing finished work is a flag people learn to ignore.

Each row of the workbench also has an arrow (→) that copies the source text into the translation, filling an empty field or overwriting what is there. It is saved like typing. Useful when most of a text stays the same (names, figures, links) or when a long text is easier to edit than to retype.

bash
php bin/console content-blocks:i18n:status                       # every area, every locale
php bin/console content-blocks:i18n:status --locale=de --incomplete   # exit non-zero if not ready

Machine translation ​

Register a provider and both the per-field button and the whole-page button work through it:

php
final class MyProvider implements TranslationProviderInterface
{
    public function getName(): string { return 'mine'; }
    public function getLabel(): string { return 'My engine'; }
    public function supports(string $source, string $target): bool { return true; }

    /** @param list<TranslationRequest> $requests @return list<TranslationOutcome> */
    public function translate(array $requests, TranslationJob $job): array { /* … */ }
}

It is autoconfigured; implementing the interface is enough.

The contract is a batch on purpose. A page is 50–200 short strings, and one HTTP call per string is slow enough that editors stop using the feature. A per-field click passes a list of one, so there is no second code path to keep in step. Return one outcome per request matched by path, throw only for whole-batch failures, and never persist anything yourself — results go back through the ordinary write gate, so the allow-list and the digests apply to machine output exactly as they do to typing.

The package ships no adapter for any engine, on purpose. Which service a page's text may be sent to — and whether it may leave the building at all — is a decision about cost, quality and confidentiality that belongs to the host; it should not arrive as a transitive dependency of a page builder. Same call as the LiipImagine integration: the seam belongs in the package, a vendor in every host's require does not.

Writing one is a single class — implementing the interface is the whole registration. Machine translation with LibreTranslate is the full worked recipe: a self-hosted engine, a complete adapter, and the failure paths spelled out. The sandbox's PseudoTranslationProvider is the smallest possible one: offline, deterministic, no credentials, and what the demo and the e2e suite run on.

A real engine is the same shape. A translation API (DeepL, Google, Azure) or a self-hosted one (LibreTranslate) maps almost directly onto it, since TranslationRequest::isHtml() already says which calls need the engine's markup mode. An LLM fits too, and can use context a dedicated engine ignores: that this string is a button label rather than a heading, a glossary, a tone.

With no provider registered, the workbench renders no machine-translation affordance at all — no ⚡ on a field, no "translate this page", no engine picker. Manual translation is unaffected. A button that can only fail is worse than an absent one, and a provider is also skipped for a page whose language pair its supports() rejects.

Bulk, for starting a translation project without clicking through 200 pages:

bash
php bin/console content-blocks:i18n:translate            # everything, every locale
php bin/console content-blocks:i18n:translate 42 --locale=de --overwrite

It writes to the draft — a machine pass is a first draft, not a release.

How it is stored ​

A side table, cb_block_translation, one row per block per locale, holding a flat map of field path to value:

json
{"title": "Bienvenue", "items[9f2c1a].label": "Livraison rapide"}

Two decisions worth knowing when you go looking:

A side table, not an envelope inside Block.data. An envelope rides along every clone and export for free; it is also opaque, so "which pages are missing German?" would mean deserializing every block's JSON — and a multilingual site is run from exactly that view. The cost is the mirror image: every flow that duplicates or serializes a block has to be taught to carry its rows — which BlockCloneObserverInterface (duplicate, insert-content), ContentAreaTransferExtensionInterface (export, import) and SnapshotExtensionInterface (section templates, copy/paste) make possible — plus a prefetch so a translated page is one query rather than one per block.

In practice that means an exported page comes back translated: the JSON carries a extensions."content-blocks/i18n" fragment holding each block's values and staleness digests per locale, and importing it into an installation without this package simply skips that fragment. Copy/paste and saved section templates carry them too, under the same key: a translated section saved to the library, or copied, arrives translated.

Tab titles have their own table. A section shown as tabs or as an accordion puts its column names on the page, so the workbench lists them as Tabs or Accordion entries before that section's blocks, and they count in the progress bar. They are stored in cb_column_translation and follow the same rules: draft until Publish, duplicated with the section, carried by export/import, translatable by your machine provider. Titles of a section shown side by side are not listed, since no visitor sees them.

Collection entries are keyed by _id, never by position. Reordering, duplicating or deleting a card shifts every position after it; keying per-entry translations by index would attach the German title of card 1 to card 3. An entry predating the _id backfill is skipped rather than guessed at — run content-blocks:backfill-collection-ids to normalize it.

The back arrow ​

The workbench's ← leads to the page itself by default — the URL your ContentAreaUrlResolverInterface returns. When translators start from your admin, send them back there instead by aliasing WorkbenchBackUrlResolverInterface:

php
use ContentBlocks\Entity\ContentArea;
use ContentBlocks\I18n\Workbench\WorkbenchBackUrlResolverInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

#[AsAlias(WorkbenchBackUrlResolverInterface::class)]
final class AdminBackUrlResolver implements WorkbenchBackUrlResolverInterface
{
    public function __construct(
        private readonly PageRepository $pages,
        private readonly UrlGeneratorInterface $urls,
    ) {}

    public function resolve(ContentArea $area, string $locale): string
    {
        $page = $this->pages->findOneBy(['contentArea' => $area]);

        return $this->urls->generate('admin_page_edit', ['id' => $page?->getId()]);
    }
}

The resolver receives the locale the workbench is open on, so it can return to a per-language tab. It is the same shape as the preview resolver, and nothing in the template needs overriding.

The workbench topbar can link the published page in every language — FR EN DE ES, the one being translated highlighted — so a translator checks the live result without leaving the list. The package cannot build those URLs: one host spells a locale as a path prefix, another as a subdomain. So nothing is linked until the host implements LocalizedPageUrlResolverInterface:

php
use ContentBlocks\Entity\ContentArea;
use ContentBlocks\I18n\Locale\LocalizedPageUrlResolverInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(LocalizedPageUrlResolverInterface::class)]
final class LocalizedPageUrlResolver implements LocalizedPageUrlResolverInterface
{
    public function resolve(ContentArea $area, string $locale): ?string
    {
        $page = $this->pages->findOneBy(['contentArea' => $area]);
        if ($page === null) {
            return null;
        }

        return $locale === 'en'   // the source locale
            ? $this->urls->generate('page_show', ['id' => $page->getId()])
            : $this->urls->generate('page_show_localized', ['_locale' => $locale, 'id' => $page->getId()]);
    }
}

It is called for the source locale and for each target. Returning null skips that language. To hide the links while keeping the resolver — for another use, or on one environment — set content_blocks_i18n.workbench.public_links: false.

The same resolver gives search engines the page in every language. Put one call in your public layout's <head>:

twig
<head>
    {{ cb_i18n_hreflang(page.contentArea) }}
</head>
html
<link rel="alternate" hreflang="en" href="https://example.com/page/7">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/page/7">
<link rel="alternate" hreflang="pt-BR" href="https://example.com/pt_BR/page/7">
<link rel="alternate" hreflang="x-default" href="https://example.com/page/7">
  • Every language with a URL, the current one included, as search engines expect: each page lists itself. A language your resolver returns null for is left out, so that is where to skip a language you don't want indexed yet.
  • x-default is the source locale's URL. Pass x_default: false to leave it out: cb_i18n_hreflang(page.contentArea, x_default: false).
  • URLs are made absolute from the current request when the resolver returns a path. Search engines ignore a relative alternate.
  • Locale codes become BCP 47 tags: pt_BR is written pt-BR.
  • Nothing is printed for a page in a single language, or when the area is null, so the call can stay in a shared layout.

For markup of your own, cb_i18n_alternates(area) returns the same list:

twig
{% for link in cb_i18n_alternates(page.contentArea) %}
    <a href="{{ link.url }}" hreflang="{{ link.hreflang }}">{{ link.locale }}</a>
{% endfor %}

Adding to the workbench ​

The workbench template carries empty Twig blocks for a host to fill, without copying the page:

twig
{# templates/bundles/ContentBlocksI18nBundle/workbench/workbench.html.twig #}
{% extends '@!ContentBlocksI18n/workbench/workbench.html.twig' %}

{% block cb_wb_head %}
    <link rel="stylesheet" href="{{ asset('admin/workbench-theme.css') }}">
{% endblock %}
BlockWhere
cb_wb_headEnd of <head> — a stylesheet redeclaring the tokens below
cb_wb_topbar_left_endLeft of the topbar, after the language pair
cb_wb_topbar_right_startRight of the topbar, first
cb_wb_topbar_right_endRight of the topbar, last
cb_wb_endLast thing inside .cb-wb

A block sees the page's variables — area and locale included. The page loads none of the host's JavaScript, so a script added here has to be self-contained.

Theming the workbench ​

The workbench is a standalone page served by the package, so it carries its own stylesheet rather than reusing the builder's chrome tokens. Fifteen custom properties on :root cover it, and redeclaring them is the supported way to make it sit inside the host's admin:

GroupTokens
Surfaces--cb-wb-bg (#f4f6f8), --cb-wb-surface (#ffffff), --cb-wb-border (#dfe4ea)
Text--cb-wb-text (#1f2933), --cb-wb-muted (#6b7785)
Accent--cb-wb-accent (#0e7490), --cb-wb-accent-soft (#e0f2f7)
Field states--cb-wb-ok (#15803d), --cb-wb-outdated (#b45309), --cb-wb-outdated-soft (#fef3c7), --cb-wb-missing (#9ca3af), --cb-wb-danger (#b91c1c)
Geometry--cb-wb-radius (8px), --cb-wb-topbar-h (52px), --cb-wb-meter-h (56px)

The four field-state colors are the ones worth overriding first: they are what tells a translator at a glance which fields are done, stale or missing, so they should read as your admin's status colors rather than ours.

These names are public surface, covered by the package's semver guarantee.

See also ​

Released under the MIT License.