Skip to main content
Wintertrace
Browse the documentation

Building modules

Translations

Provide translatable strings from a module: the namespaced lang files, how the ModuleManager loads them automatically, and how to reference keys.

The core ships with German (de) and English (en) out of the box. Your module places its own strings alongside those, and — since 1.1.3 — can even add a completely new UI language through the LocaleRegistry, without touching any core file.

Translation files in a module

A module’s translation files live in its lang/ directory, separated by language code:

modules/my-module/
  lang/
    de/
      messages.php
    en/
      messages.php

File format

Standard Laravel translation arrays — nothing module-specific. Anyone familiar with Laravel can start immediately:

<?php
// lang/de/messages.php
return [
    'settings' => 'Einstellungen',
    'upload' => 'Hochladen',
    'document_types' => [
        'contract' => 'Vertrag',
        'invoice' => 'Rechnung',
        'other' => 'Sonstiges',
    ],
];
<?php
// lang/en/messages.php
return [
    'settings' => 'Settings',
    'upload' => 'Upload',
    'document_types' => [
        'contract' => 'Contract',
        'invoice' => 'Invoice',
        'other' => 'Other',
    ],
];

Using translations

Every module string is namespaced with the module slug — {slug}::file.key. The ModuleManager registers the namespace automatically during boot; you do not need to call loadTranslationsFrom() for normal module strings.

// In PHP
__('my-module::messages.settings');  // → 'Einstellungen' (if locale is de)

// With parameters
__('my-module::messages.uploaded_by', ['name' => 'Max']);
{{-- In Blade --}}
{{ __('my-module::messages.settings') }}

@lang('my-module::messages.upload')

Do not translate at boot time — pass keys to registries

This is the most important rule on this page. __() always resolves against the current locale at the moment it runs. During your ServiceProvider::boot(), only the fallback locale (de) is active — the per-user and per-customer locale is applied later, per request. A __() call in boot() therefore freezes its result to German.

This is a problem wherever you hand a value to a registry that the core renders later — for example a menu label:

// ❌ Freezes the menu label to German — page content translates, the menu does not.
$nav->addItem(label: __('my-module::messages.nav_label'), ...);

// ✅ Pass the raw key; the NavigationRegistry resolves it lazily per request.
$nav->addItem(label: 'my-module::messages.nav_label', ...);

Rule of thumb: in boot(), pass translation keys to navigation and portal registries — they resolve lazily at render time. Only call __() where the value is consumed immediately: inside Blade views, controllers, or per-request closures. More on this under Navigation & dashboard.

Nested keys

Nested arrays are addressed with dot notation:

__('my-module::messages.document_types.contract'); // → 'Vertrag'

Pluralisation

For quantity strings, use Laravel’s plural syntax with trans_choice():

// lang/de/messages.php
return [
    'documents_count' => '{0} Keine Dokumente|{1} Ein Dokument|[2,*] :count Dokumente',
];

// Usage
trans_choice('my-module::messages.documents_count', 5); // → '5 Dokumente'

Brand-neutral strings with brand()

The core uses a BrandedTranslator that automatically replaces brand references. This is why you should not hard-code the product name in strings. Write the placeholder :brand instead, and the same string will display the correct brand name depending on the installation:

// Don't do this:
return ['welcome' => 'Willkommen bei Schneespur'];

// Do this instead:
return ['welcome' => 'Willkommen bei :brand'];

// Usage:
__('my-module::messages.welcome', ['brand' => brand()]);

Localised module.json

The name and description fields in module.json can also be localised — useful so that your module appears in the correct language in the module management page:

{
    "name": {
        "de": "Dokumentenverwaltung",
        "en": "Document Management"
    },
    "description": {
        "de": "Verwaltet Verträge und Dokumente für Kunden.",
        "en": "Manages contracts and documents for customers."
    }
}

The Module model’s pickLocalized() method selects the current app locale and falls back to English if the requested locale is missing.

Registering a new locale (language packs)

Since 1.1.3 a module can add a new UI language through the LocaleRegistry (a singleton). The core registers de and en; your module adds more. Czech (cs), for instance, can be added without changing a single core file.

Step 1 — register the locale code in your ServiceProvider::boot():

use App\Services\Extension\LocaleRegistry;

public function boot(): void
{
    app(LocaleRegistry::class)->add('cs', 'Čeština');

    // Your module's own strings (namespaced):
    $this->loadTranslationsFrom(__DIR__ . '/../lang', 'my-module');
}

Once registered, cs is accepted everywhere a locale is validated or applied — in customer and user validation, the EnsureCustomer/EnsureDriver middleware, admin picker lists, and the installer. All these places query LocaleRegistry::codes() or has() rather than a hard-coded de,en list. That is why a single add() call is enough — you do not need to update ten different places.

Step 2 — translate the core UI. Module-namespaced strings (my-module::…) only cover your module’s screens. To translate the application UI (admin, portal, driver view) into the new language, core language files must exist under lang/<code>/ using the same namespaces as lang/de/. Laravel resolves core strings from the root namespace; a module namespace does not override them. There are two options:

  1. The language-pack module copies its lang/cs/ files into the installation’s core lang/ directory on activation.
  2. The lang/cs/ files are shipped with the core.

Boot timing. Module locales are registered during ModuleManager::boot(), which runs inside the app()->booted() callback. The application-wide default_locale is therefore applied after modules boot — a pure-cs installation resolves correctly.

The full API of the LocaleRegistry is documented under Registries.

Per-user, per-driver, and per-customer locale

The UI language can be assigned to an individual actor; they see the application in that language after logging in.

ActorColumnWhere it is setWhere it is applied
Customercustomers.localePortal profile (self-service) or admin customer formEnsureCustomer middleware (per request)
Driver / Adminusers.locale (nullable)Admin → Users formEnsureDriver middleware (per request)

A null in users.locale means “use the application-wide default”. Both middlewares apply the locale only if LocaleRegistry::has($code) — an unregistered or removed locale falls back to the default safely. The language <select> lists are built from LocaleRegistry::labels(), so every registered language appears automatically.

A practical example: a German installation with one Czech driver. The operator installs the Czech language pack and sets that driver’s language to cs. The driver logs in and sees the entire driver UI in Czech, while everyone else stays on German.

Application-wide default locale

The application’s default locale is stored in the default_locale setting (Admin → Company settings) and accepts any registered locale. It is applied inside the app()->booted() callback of AppServiceProvider, after modules register their locales, guarded by LocaleRegistry::has().

If the default is any locale other than de, the brand resolves to Wintertrace (see the dual-brand model under Architecture). A fully Czech installation is therefore automatically branded Wintertrace.