---
title: "Hooks"
description: "Preprocess, postprocess, or skip content with before/after hooks. Surface dropped translations with the warning hook."
canonical_url: "https://kirby.tools/docs/content-translator/advanced/hooks"
---

# Hooks

> Preprocess, postprocess, or skip content with before/after hooks. Surface dropped 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.

<table>
<thead>
  <tr>
    <th>
      Hook
    </th>
    
    <th>
      When
    </th>
    
    <th>
      Return
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        content-translator.translate:before
      </code>
    </td>
    
    <td>
      Before a unit is sent to the strategy
    </td>
    
    <td>
      Modified text (string)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        content-translator.translate:after
      </code>
    </td>
    
    <td>
      After the strategy returns a translation
    </td>
    
    <td>
      Modified text (string)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        content-translator.translate:warning
      </code>
    </td>
    
    <td>
      When a unit is dropped (per-unit failure)
    </td>
    
    <td>
      n/a (event-style)
    </td>
  </tr>
</tbody>
</table>

<warning>

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 translations go through the plugin's batch endpoint whether they start in the Panel or in your own code, and both `Translator` entry points run the full pipeline. Terminology enforcement, logging, or drop alerting wired up here therefore skips Panel AI translations silently.

</warning>

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`.

<field-group>
<field name="text" type="String">

The text about to be translated.

</field>

<field name="targetLanguage" type="String">

Target language code (`de`, `fr`, `en-gb`, …).

</field>

<field name="sourceLanguage" type="String | null">

Source language code, or `null` if not specified.

</field>

<field name="type" type="String">

Always `text` for now. Reserved for future expansion.

</field>

<field name="unit" type="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](#field-aware-preprocessing).

</field>

<field name="options" type="ExecutionOptions">

Typed `ExecutionOptions` carrying both `targetLanguage` and `sourceLanguage` as `TranslationLanguage` value objects.

</field>
</field-group>

<note>

Kirby's `apply()` matches by parameter name, so a closure only has to declare the payload keys it uses.

</note>

```php [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.

<field-group>
<field name="text" type="String">

The translated text returned by the strategy.

</field>

<field name="originalText" type="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`.

</field>

<field name="targetLanguage" type="String">

Target language code.

</field>

<field name="sourceLanguage" type="String | null">

Source language code.

</field>

<field name="type" type="String">

Always `text` for now.

</field>

<field name="unit" type="TranslationUnit">

The unit that was sent.

</field>

<field name="options" type="ExecutionOptions">

The execution options.

</field>
</field-group>

```php [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 **dropped**. The unit keeps its source text. Silent by default – wire it to logging, Sentry, or Slack to surface drops in production.

How much survives depends on where the drop happened. `missing translation`, `non-string translation`, `empty translation` and `placeholder mismatch` are caught after the strategy returns, so they apply to every strategy – including a custom one – and only the affected unit is reverted. The remaining reasons come from the strategy itself: `CopilotAIStrategy` drops per unit and per chunk, so the rest of the batch still gets written, while `DeepLStrategy` sends the batch in one request and, when it fails, fires a warning for every unit and then throws `TranslationException`, so nothing from that batch is stored. Treat a DeepL warning as a batch-level failure that happens to be reported per unit.

<warning>

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 from the Panel goes through the batch endpoint and does fire.

</warning>

<field-group>
<field name="unit" type="TranslationUnit">

The unit that was dropped.

</field>

<field name="reason" type="String">

Short tag explaining the drop (see table below).

</field>

<field name="previous" type="Throwable | null">

The upstream exception, when applicable.

</field>
</field-group>

### Drop Reasons

<table>
<thead>
  <tr>
    <th>
      Reason
    </th>
    
    <th>
      Strategy
    </th>
    
    <th>
      Cause
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        <upstream error message>
      </code>
    </td>
    
    <td>
      <code>
        DeepLStrategy
      </code>
    </td>
    
    <td>
      Upstream batch request threw
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <upstream error message>
      </code>
    </td>
    
    <td>
      <code>
        CopilotAIStrategy
      </code>
    </td>
    
    <td>
      Provider call threw (rate limit, network, auth)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        response length mismatch
      </code>
    </td>
    
    <td>
      <code>
        CopilotAIStrategy
      </code>
    </td>
    
    <td>
      AI returned the wrong number of translations
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        placeholder mismatch
      </code>
    </td>
    
    <td>
      any
    </td>
    
    <td>
      Translation lost, invented or repeated a <code>
        <cN/>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        non-string translation
      </code>
    </td>
    
    <td>
      any
    </td>
    
    <td>
      Strategy returned a non-string entry for the unit
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        empty translation
      </code>
    </td>
    
    <td>
      any
    </td>
    
    <td>
      Strategy returned a string holding nothing but whitespace
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        missing translation
      </code>
    </td>
    
    <td>
      any
    </td>
    
    <td>
      Strategy returned fewer entries than it was given units
    </td>
  </tr>
</tbody>
</table>

### Example: Send Drops to Sentry

```php [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 dropped: ' . $reason);
            });
        },
    ],
];
```

<warning>

A drop means the field still contains its source-language text. End users see English where they expected German. Treat warning volume as an SLO signal.

</warning>

## 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 the batch endpoint, 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:

```php [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.

<warning>

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

</warning>

---

Every page of this site as Markdown: <https://kirby.tools/sitemap.md>
