Overview

Pluggable translation backends – pick DeepL, Copilot AI, or a callable, or implement your own to route through any service.

A Strategy receives a list of TranslationUnits, returns translations in the same order, and stays stateless across calls. Built-in strategies handle batching and partial failures – per-unit failures keep source text and emit a warning hook; only zero survivors throw.

Available since v3.11. The deprecated translateFn config option still works and is wrapped automatically in a CallableStrategy.

The Interface

namespace JohannSchopplich\ContentTranslator\Translation;

interface Strategy
{
    /**
     * @param list<TranslationUnit> $units
     * @return list<string>
     *
     * @throws TranslationException When zero units could be translated.
     */
    public function execute(array $units, ExecutionOptions $options): array;
}

The contract is short: return one translation per input in the same order, and stay stateless across calls. Throw TranslationException only when zero units survived – per-unit failures keep the source text and trigger content-translator.translate:warning. The typed payloads passed in are TranslationUnit and ExecutionOptions.

Built-in Implementations

DeepLStrategy

Default. Wraps the DeepL HTTP client, batches up to 50 texts per request.

CopilotAIStrategy

Routes through Kirby Copilot. Chunks by item count and char budget, validates placeholder counts, drops responses with mismatched lengths.

CallableStrategy

Adapts a Closure(string $text, string $target, ?string $source): string to the strategy contract.

Strategy Resolution

Translator resolves the active strategy in this order, first match wins:

1. Method Parameter

The ?Strategy $strategy argument on translateText, translateTexts, and translateContent.

Translator::translateText('Hello', 'de', 'en', new DeepLStrategy());

2. strategy Config Option

Accepts a string preset, a closure, or a Strategy instance.

config.php
return [
    'johannschopplich.content-translator' => [
        // String presets
        'strategy' => 'deepl', // or 'ai' (requires kirby-copilot)

        // …or a closure
        'strategy' => fn (string $text, string $target, ?string $source) => /* … */,

        // …or a Strategy instance
        'strategy' => new MyCustomStrategy(),
    ],
];

3. Legacy translateFn deprecated

Kept for back-compat. Wrapped in CallableStrategy automatically.

4. Default

new DeepLStrategy().

'strategy' => 'ai' throws LogicException when Kirby Copilot is not installed. An unknown string throws LogicException('Unknown strategy "<name>"').

Implementing a Custom Strategy

Implement the interface, attempt each unit, and only throw when zero units survive – matching the failure pattern of the built-in strategies:

use JohannSchopplich\ContentTranslator\Translation\Exception\TranslationException;
use JohannSchopplich\ContentTranslator\Translation\ExecutionOptions;
use JohannSchopplich\ContentTranslator\Translation\Strategy;
use JohannSchopplich\ContentTranslator\Translation\TranslationUnit;
use Kirby\Cms\App;

final class MyApiStrategy implements Strategy
{
    public function execute(array $units, ExecutionOptions $options): array
    {
        // Pre-fill with source so failed units keep the original text
        $results = array_map(fn (TranslationUnit $u) => $u->text, $units);
        $translatedCount = 0;
        $lastError = null;

        foreach ($units as $i => $unit) {
            try {
                $results[$i] = myTranslate(
                    text: $unit->text,
                    target: $options->targetLanguage->code,
                    source: $options->sourceLanguage?->code,
                );
                $translatedCount++;
            } catch (Throwable $error) {
                $lastError = $error;
                App::instance()->trigger('content-translator.translate:warning', [
                    'unit' => $unit,
                    'reason' => $error->getMessage(),
                    'previous' => $error,
                ]);
            }
        }

        if ($translatedCount === 0) {
            throw new TranslationException(
                strategy: 'my-api',
                reason: $lastError?->getMessage() ?? 'unknown error',
                unitsAttempted: count($units),
            );
        }

        return $results;
    }
}

The three moves to remember:

  1. Pre-fill $results with source text so a per-unit failure preserves the existing content.
  2. Emit content-translator.translate:warning for each dropped unit – listeners can log or alert.
  3. Throw TranslationException only when zero units survived – partial success is success.

Wire it up:

config.php
return [
    'johannschopplich.content-translator' => [
        'strategy' => new MyApiStrategy(),
    ],
];
If your backend supports batching, prefer implementing Strategy directly over passing a closure. CallableStrategy translates one text at a time; a real Strategy receives the full unit array, can batch, can route via fieldKey, and can decide per-unit whether to translate or pass through.