Overview
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.
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.
Two things are handled for you, so a custom strategy never has to repeat them:
- Untranslatable text never arrives. Blanks, pure numbers, standalone URLs, and prose that is only KirbyTag placeholders are filtered out before
execute()is called, and their source text is spliced back into the result. A batch with nothing else left never calls the strategy at all – so$unitsis never empty. - The result is validated afterwards. Any unit that lost or invented a
<cN/>token – and any slot that comes back missing or as a non-string – is reverted to source and reported through the warning hook, whichever strategy produced it.
Built-in Implementations
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
return [
'johannschopplich.content-translator' => [
'strategy' => 'deepl',
],
];
Four value types are accepted: the string presets 'deepl' and 'ai', a closure with the signature of CallableStrategy, or an instance of any class implementing Strategy.
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:
- Pre-fill
$resultswith source text so a per-unit failure preserves the existing content. - Emit
content-translator.translate:warningfor each dropped unit – listeners can log or alert. - Throw
TranslationExceptiononly when zero units survived – partial success is success.
Wire it up:
return [
'johannschopplich.content-translator' => [
'strategy' => new MyApiStrategy(),
],
];
This works as long as the constructor stays inert. A constructor that reads Kirby options or calls App::instance() runs too early here – wrap the instantiation in Kirby's ready callback instead, as shown under DeepLStrategy.
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.