Hooks

Preprocess or postprocess content with before/after hooks, and surface rejected translations with the warning hook.

The plugin exposes three Kirby hooks. They fire for every individual text the pipeline processes – including text inside blocks, structures, layouts, and table cells.

HookWhenReturn
content-translator.translate:beforeBefore a unit is sent to the strategyModified text (string)
content-translator.translate:afterAfter the strategy returns a translationModified text (string)
content-translator.translate:warningWhen a unit is rejected (per-unit failure)n/a (event-style)
Hooks live on the PHP side, and AI translation started in the Panel never gets there. It runs in the browser against Kirby Copilot, which owns the provider credentials, so none of the three hooks fire for it. Everything else does reach PHP: DeepL and custom-strategy translations reach PHP whether they start in the Panel or in your own code, and both 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.

text
String
The text about to be translated.
targetLanguage
String
Target language code (de, fr, en-gb, …).
sourceLanguage
String | null
Source language code, or null if not specified.
type
String
Always text for now. Reserved for future expansion.
unit
TranslationUnit
The full 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.
options
ExecutionOptions
Typed ExecutionOptions carrying both targetLanguage and sourceLanguage as TranslationLanguage value objects.
Kirby's apply() matches by parameter name, so a closure only has to declare the payload keys it uses.
site/config/config.php
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.

text
String
The translated text returned by the strategy.
originalText
String
The text as it was collected from the content, before any :before rewrites. To see what actually went to the strategy, read $unit->text.
targetLanguage
String
Target language code.
sourceLanguage
String | null
Source language code.
type
String
Always text for now.
unit
TranslationUnit
The unit that was sent.
options
ExecutionOptions
The execution options.
site/config/config.php
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.

Server-side translations only. AI translations started from the Panel run against Kirby Copilot in the browser and never reach PHP, so no warning fires for them – their reasons go to the browser console. DeepL and custom strategies from the Panel reach PHP and do fire.
unit
TranslationUnit
The unit that was rejected.
reason
String
Short tag explaining the rejection (see table below).
previous
Throwable | null
The upstream exception, when applicable.

Rejection Reasons

ReasonStrategyCause
<upstream error message>DeepLStrategyUpstream batch request threw
<upstream error message>CopilotAIStrategyProvider call threw (rate limit, network, auth)
response length mismatchCopilotAIStrategyAI returned the wrong number of translations
placeholder mismatchanyTranslation lost, invented, or repeated a <cN/>
non-string translationanyStrategy returned a non-string entry for the unit
empty translationanyStrategy returned a string holding nothing but whitespace
missing translationanyStrategy returned fewer entries, or null for the unit
A custom strategy that returns 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

site/config/config.php
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);
            });
        },
    ],
];
A rejection means the field still contains its source-language text. End users see English where they expected German. Treat warning volume as an SLO signal.

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:

site/config/config.php
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.

The :after hook applies to the final stored content. Bugs in your hook propagate to disk – review carefully before deploying.