Hooks
The plugin exposes three Kirby hooks. They fire for every individual text the pipeline processes – including text inside blocks, structures, layouts, and table cells.
| Hook | When | Return |
|---|---|---|
content-translator.translate:before | Before a unit is sent to the strategy | Modified text (string) |
content-translator.translate:after | After the strategy returns a translation | Modified text (string) |
content-translator.translate:warning | When a unit is rejected (per-unit failure) | n/a (event-style) |
Translator entry points run the full pipeline. Terminology enforcement, logging, or rejection alerting wired up here therefore skips Panel AI translations silently.On the paths where they do fire, :before and :after run for every unit holding content, including values no provider ever sees – a field that is just a price or a URL still runs both hooks, with the text unchanged. Inspect $text if a hook should only act on prose.
content-translator.translate:before
Rewrite text on its way to the strategy. The returned string replaces the unit's text and is always sent on – the hook cannot skip a unit or cancel the translation. To keep content out of translation entirely, narrow fieldTypes or list the field in excludeFields.
de, fr, en-gb, …).null if not specified.text for now. Reserved for future expansion.TranslationUnit (text, fieldKey). Lets you branch on the originating field or table cell – but only where there is one, see Field-Aware Preprocessing. A field inside a structure, object, blocks, or layout field leads with its container: body.text, not text.ExecutionOptions carrying both targetLanguage and sourceLanguage as TranslationLanguage value objects.apply() matches by parameter name, so a closure only has to declare the payload keys it uses.return [
'hooks' => [
'content-translator.translate:before' => function ($text) {
// Strip internal editorial markers so they never reach the provider
return str_replace(['[draft]', '[review]'], '', $text);
}
]
];
content-translator.translate:after
Postprocess translated text – language-specific formatting, terminology enforcement, logging.
:before rewrites. To see what actually went to the strategy, read $unit->text.text for now.return [
'hooks' => [
'content-translator.translate:after' => function ($text, $originalText, $targetLanguage) {
// Restore untranslatable terms
$protected = ['API', 'CSS', 'HTML', 'JavaScript'];
foreach ($protected as $term) {
$text = preg_replace('/\b' . $term . '\b/i', $term, $text);
}
return $text;
}
]
];
content-translator.translate:warning
Fires once per unit that was rejected. The unit keeps its source text. Silent by default – wire it to logging, Sentry, or Slack to surface rejections in production.
When DeepL rejects a request, every unit in the batch fires this hook and TranslationException follows. Treat a DeepL warning as a batch-level failure that happens to be reported per unit.
Rejection Reasons
| Reason | Strategy | Cause |
|---|---|---|
<upstream error message> | DeepLStrategy | Upstream batch request threw |
<upstream error message> | CopilotAIStrategy | Provider call threw (rate limit, network, auth) |
response length mismatch | CopilotAIStrategy | AI returned the wrong number of translations |
placeholder mismatch | any | Translation lost, invented, or repeated a <cN/> |
non-string translation | any | Strategy returned a non-string entry for the unit |
empty translation | any | Strategy returned a string holding nothing but whitespace |
missing translation | any | Strategy returned fewer entries, or null for the unit |
null for a unit is recorded as a missing translation rejection, but this hook does not fire for it – the strategy owns that warning, being the only layer that knows the real reason. If you alert on rejections, emit the hook from your own strategy.Example: Send Rejections to Sentry
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
return [
'hooks' => [
'content-translator.translate:warning' => function ($unit, $reason, $previous) {
withScope(function (Scope $scope) use ($unit, $reason, $previous) {
$scope->setExtra('field', $unit->fieldKey);
$scope->setExtra('text_excerpt', mb_substr($unit->text, 0, 200));
if ($previous !== null) {
$scope->setExtra('upstream_error', $previous->getMessage());
}
captureMessage('Translation rejected: ' . $reason);
});
},
],
];
Field-Aware Preprocessing
The unit payload key lets you branch on the originating field. fieldKey is only filled in when the translation walks a model's content – that is translateContent() and the CLI. Panel translations post a flat list of texts to PHP, and standalone translateTexts() calls carry no field context either, so fieldKey is null in both cases. The example below stays safe either way, because a null key never matches:
return [
'hooks' => [
'content-translator.translate:before' => function ($text, $targetLanguage, $sourceLanguage, $type, $unit) {
// Tighter prompts for headlines
if (in_array($unit->fieldKey, ['headline', 'title'], true)) {
return trim($text);
}
return $text;
}
]
];
Notes
Hooks fire for every individual text – they may run hundreds of times during a batch translation. Keep them fast.
:after hook applies to the final stored content. Bugs in your hook propagate to disk – review carefully before deploying.