klickmanufaktur/statamic-translator

Provider-agnostic automatic translations and translated URLs for Statamic sites.

Maintainers

Package info

github.com/klickmanufaktur/statamic-translator

Type:statamic-addon

pkg:composer/klickmanufaktur/statamic-translator

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.4 2026-05-29 10:06 UTC

This package is auto-updated.

Last update: 2026-05-29 10:08:13 UTC


README

Provider-independent automatic translations for Statamic without Multisite. The addon creates locale-specific URLs such as /en/service/lifesituations, translates rendered HTML at runtime, rewrites internal links, and provides tags for frontend language switchers.

Installation

Install the addon from Packagist:

composer require klickmanufaktur/statamic-translator
php artisan migrate

Optionally publish the configuration into the project:

php artisan vendor:publish --tag=statamic-translator-config

If the package is not auto-discovered in your Statamic project, add the service provider manually to bootstrap/providers.php in Laravel 11 or newer:

return [
    App\Providers\AppServiceProvider::class,
    Klickmanufaktur\StatamicTranslator\ServiceProvider::class,
];

For older Laravel versions, register the provider in the providers array of config/app.php.

Local Development Installation

When working on the addon locally, add it as a Composer path repository in your project instead:

{
  "repositories": [
    {
      "type": "path",
      "url": "../_packages/statamic-translator",
      "options": {
        "symlink": true
      }
    }
  ],
  "require": {
    "klickmanufaktur/statamic-translator": "*"
  }
}

Then install or update the local package:

composer update klickmanufaktur/statamic-translator --with-dependencies --minimal-changes

Configuration

Languages are configured in config/statamic-translator.php.

'source' => [
    'locale' => 'de',
    'name' => 'Deutsch',
    'label' => 'DE',
    'switcher_name' => 'German',
    'native_name' => 'Deutsch',
    'translation_notice' => [
        'title' => 'Hinweis zur automatisierten Übersetzung',
        'body' => 'Die ausgewählte Sprachfassung wird automatisiert erstellt. Trotz sorgfältiger technischer Umsetzung können Fehler, Auslassungen oder Abweichungen auftreten. Maßgeblich und rechtlich verbindlich ist ausschließlich die deutsche Originalfassung.',
        'cancel_label' => 'Abbrechen',
        'confirm_label' => 'Verstanden, fortfahren',
    ],
],

'locales' => [
    'en' => [
        'name' => 'English',
        'label' => 'EN',
        'switcher_name' => 'English',
        'native_name' => 'English',
        'deepl_target' => 'EN-US',
        'translation_notice' => [
            'title' => 'Notice about automatic translation',
            'body' => 'The selected language version is generated automatically. Despite careful technical implementation, errors, omissions or deviations may occur. Only the German original version is authoritative and legally binding.',
            'cancel_label' => 'Cancel',
            'confirm_label' => 'I understand, continue',
        ],
    ],
    'fr' => [
        'name' => 'Français',
        'label' => 'FR',
        'switcher_name' => 'French',
        'native_name' => 'français',
        'deepl_target' => 'FR',
        'translation_notice' => [
            'title' => 'Remarque concernant la traduction automatique',
            'body' => 'La version linguistique sélectionnée est générée automatiquement. Malgré une mise en œuvre technique soignée, des erreurs, omissions ou divergences peuvent survenir. Seule la version originale allemande fait foi et est juridiquement contraignante.',
            'cancel_label' => 'Annuler',
            'confirm_label' => 'J’ai compris, continuer',
        ],
    ],
],

switcher_name is the highlighted language name in the switcher. native_name is the language name in that language. translation_notice is emitted per target language and should therefore be maintained in the target language.

Enable DeepL through .env:

STATAMIC_TRANSLATOR_ENABLED=true
DEEPL_API_KEY=your-key
STATAMIC_TRANSLATOR_PROVIDER=deepl
STATAMIC_TRANSLATOR_SOURCE_LOCALE=de
STATAMIC_TRANSLATOR_FALLBACK_TO_SOURCE_ON_ERROR=true

Set STATAMIC_TRANSLATOR_ENABLED=false to disable the translator completely. Locale routes return 404, HTML translation is skipped, URL generation and prewarming commands stop early, and translator:enabled returns false. This can be used to hide the frontend language switcher. If Statamic Static Caching is enabled, also run php please static:clear when disabling the translator so previously generated static locale pages are no longer served.

STATAMIC_TRANSLATOR_FALLBACK_TO_SOURCE_ON_ERROR=true prevents locale URLs from failing with a 500 response when the provider or API key is unavailable. The source-language page is rendered and the error is logged.

The DeepL endpoint is selected automatically. API keys ending in :fx use https://api-free.deepl.com; all other keys use https://api.deepl.com. You can override the endpoint explicitly:

DEEPL_API_URL=https://api.deepl.com/v2/translate

Authentication uses DeepL's Authorization: DeepL-Auth-Key ... header. The legacy auth_key request-body parameter is not used.

Text translations are sent as JSON:

{
  "text": ["Hallo Welt"],
  "target_lang": "EN-US",
  "source_lang": "DE",
  "preserve_formatting": true
}

Requests are split automatically according to DeepL limits: at most 50 texts and at most 128 KiB request body size.

Slugs and Protected Terms

For stable or cheaper URLs, define slug translations manually. Segments in preserve_segments are never translated and are copied as-is, which is useful for place names.

'slugs' => [
    'preserve_segments' => [
        'service',
        'weiler',
        'haubersbronn',
    ],
    'overrides' => [
        'en' => [
            'lebenslagen' => 'lifesituations',
        ],
    ],
],

Proper nouns can be configured as protected terms. They are replaced with internal placeholders before the provider request and restored after translation. This keeps district names, person names, brand names, and similar terms unchanged in body text.

'protected_terms' => [
    'global' => [
        'Schorndorf',
        'Weiler',
        'Haubersbronn',
    ],
    'locales' => [
        'en' => [
            'Rems-Murr-Kreis',
        ],
    ],
],

Address fields and address-like texts are excluded centrally from translation. This affects cost estimates, prewarming, and frontend HTML translation.

'content' => [
    'ignored_keys' => [
        'address',
        'address_street',
        'address_city',
        'address_district',
        'street',
        'postal_code',
        'city',
        'ort',
        'district',
        'location',
    ],
    'ignored_text_patterns' => [
        '~\b\d{5}\s+[\pL][\pL\s.\'-]{1,80}\b~u',
        '~\b[\pL][\pL\s.\'-]*(?:straße|strasse|weg|platz|gasse|allee|ring)\s+\d+[a-z]?\b~iu',
    ],
],

Values such as Burgstraße 67, Schorndorf or 73614 Schorndorf remain unchanged. For intentionally untranslated template sections, add translate="no" or data-no-translate="true".

Switching Providers

A new provider must implement Klickmanufaktur\StatamicTranslator\Contracts\TranslationProvider. Register it in the config:

'provider' => env('STATAMIC_TRANSLATOR_PROVIDER', 'custom'),

'providers' => [
    'custom' => [
        'class' => App\Translation\CustomTranslationProvider::class,
    ],
],

The rest of the addon stays unchanged because all translations go through the provider interface.

Commands

Estimate translation costs:

php artisan translator:estimate --locale=en
php artisan translator:estimate --collection=pages --locale=en

Generate translated URLs ahead of time:

php artisan translator:urls --locale=en
php artisan translator:urls --collection=pages --locale=en

Existing locale URLs are refreshed automatically by the frontend tag when routing.refresh_existing_urls is enabled. This corrects old fallback URLs once translated slugs are available. If a provider error occurs, no new fallback URL is stored; an existing link is only used as a fallback display value.

Internal links in translated HTML responses are rewritten to the active locale when a translated target URL exists. Missing link URLs are not generated through DeepL during normal page rendering by default, so a first page view does not trigger many slug requests.

To avoid bare default-language links, missing entry links are rendered as /{locale}{source-url}. The addon controller recognizes these fallback URLs, creates the canonical translated URL, and redirects with a 301. For SEO-clean links without this fallback step, run the bulk URL builder:

php artisan translator:urls --locale=fr

You may enable routing.create_missing_link_urls if needed, but this is only recommended for small page sets.

Prewarm text translations. Without --execute, the command is a dry run:

php artisan translator:warm --locale=en
php artisan translator:warm --locale=en --execute --max-chars=100000

Frontend Tags

Current language:

{{ translator:locale }}
{{ translator:label }}
{{ translator:name }}

URL for a specific language:

<a href="{{ translator:url locale='en' }}">English</a>

Translate text directly in templates. This is useful for Statamic nocache fragments because they may be rendered after response-level HTML translation:

{{ translator:translate text="Zum Veranstaltungskalender" context="event:button" }}
{{ translator:translate :text="dynamic_label" context="event:button" }}

For translated HTML attributes, use the escaped variant:

<img alt="{{ translator:translate_attribute :text='image_alt' context='image-alt' }}">
<a aria-label="{{ translator:translate_attribute text='Bild vergrößern' context='image-zoom-label' }}">

Localize an internal source URL directly in templates:

<a href="{{ translator:localized_url url='/freizeit-tourismus/veranstaltungen' }}">Events</a>
<a href="{{ translator:localized_url :url='dynamic_url' }}">Events</a>

Language list for a switcher:

{{ translator:languages }}
    {{ if available }}
        <a href="{{ url }}" hreflang="{{ locale }}" lang="{{ locale }}">
            {{ name }} ({{ label }})
        </a>
    {{ else }}
        <span aria-disabled="true">{{ name }}</span>
    {{ /if }}
{{ /translator:languages }}

Each item contains locale, name, label, url, selected, is_current, is_source, and available. It also exposes switcher_name, native_name, notice_title, notice_body, notice_cancel_label, and notice_confirm_label for language switchers and translation notices.

Alternate links for the document head:

<head>
    {{ seo }}
    {{ translator:alternate_links }}
</head>

This outputs rel="alternate" links for the source locale, x-default, and every configured translated locale. Add it to the base layout so the source-language pages expose the same hreflang links as translated addon routes.

Breadcrumbs on translated addon routes:

{{ if {translator:is_translated} }}
    {{ translator:breadcrumbs include_home="false" }}
        <a href="{{ url }}">{{ title }}</a>
    {{ /translator:breadcrumbs }}
{{ /if }}

The breadcrumb tag builds the path from the German source URI and resolves links for the active language. This is necessary because Statamic's nav:breadcrumbs does not see translated addon routes such as /fr/... as normal collection routes.

SEO

The addon sets lang, canonical, and hreflang links on translated responses, translates relevant meta tags, and provides a sitemap per language at /{locale}/sitemap.xml. Use {{ translator:alternate_links }} in your base layout so source-language pages also output hreflang alternates. Internal links to known entries are rewritten to the matching locale URL.

Static Caching

Locale routes use both web and statamic.web by default. This allows Statamic Static Caching to cache translated URLs. The first request to a locale URL creates the translated HTML response; Statamic can then cache that response per URL, for example separately for /en/... and /fr/....

Recommended after content, slug, or config changes:

php artisan config:clear
php please static:clear

When using full static caching, the web server must be configured to serve Statamic's static files as usual. Previously cached locale pages remain available until the static cache is cleared or invalidated.

Cost Control

  • Run translator:estimate before translating or prewarming content.
  • Limit commands to the collections and locales you actually need.
  • Keep translator:warm as a dry run until the estimated character count is acceptable.
  • Use slug overrides for frequent or SEO-critical URL segments.
  • Empty pages cause almost no text costs and will be translated later once content is added.