Overview

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

A strategy is the translation backend Kirby Content Translator routes through. Pick a built-in (DeepL, Copilot AI, callable) or implement Strategy to plug in any service.

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, ExecutionOptions, and TranslationMode.

Built-in Implementations

DeepLStrategy

Default. Wraps the DeepL HTTP client, batches up to 50 texts per request, falls back to single requests for TranslationMode::Single.

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 and ignores TranslationMode; 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.