T
Tenanto
Documentation / I18N

I18N

Updated Jan 25, 2026

Internationalization (i18n) Guide

This document describes how to add multi-language support to your Tenanto-based application.

Overview

Tenanto comes with a prepared i18n infrastructure that makes it easy to add translations for your application. The system supports:

Directory Structure

lang/
├── en/                      # English translations (default)
│   ├── auth.php            # Authentication pages
│   ├── billing.php         # Subscription & payment
│   ├── profile.php         # User profile
│   ├── dashboard.php       # Dashboard & general UI
│   ├── notifications.php   # Email notifications
│   ├── admin.php           # Filament admin panel
│   ├── api.php             # API responses
│   ├── errors.php          # Error pages
│   ├── common.php          # Shared UI elements
│   ├── onboarding.php      # Onboarding wizard
│   └── validation.php      # Form validation messages
├── cs/                      # Czech translations (example)
│   └── ...
└── de/                      # German translations (example)
    └── ...

Adding a New Language

Step 1: Copy English translations

cp -r lang/en lang/cs  # Replace 'cs' with your locale code

Step 2: Translate the files

Edit each .php file in your new language directory. For example, lang/cs/auth.php:

<?php

return [
    'welcome_back' => 'Vítejte zpět',
    'sign_in' => 'Přihlásit se',
    'email' => 'E-mailová adresa',
    'password' => 'Heslo',
    // ... translate all keys
];

Step 3: Register the locale

Edit app/Http/Middleware/SetTenantLocale.php and add your locale to the SUPPORTED_LOCALES array:

private const SUPPORTED_LOCALES = [
    'en',
    'cs', // Czech - add your locale here
];

Also update the getSupportedLocales() method:

public static function getSupportedLocales(): array
{
    return [
        'en' => 'English',
        'cs' => 'Čeština', // Add display name
    ];
}

Step 4: Set as default (optional)

To change the default application locale, edit .env:

APP_LOCALE=cs
APP_FALLBACK_LOCALE=en

Tenant-Level Language Settings

Each tenant can have their own preferred locale stored in their settings.

Setting tenant locale

$tenant->update([
    'settings' => array_merge($tenant->settings ?? [], [
        'locale' => 'cs',
    ]),
]);

In the onboarding wizard

The locale preference can be set during onboarding (Step 3: Preferences). The OnboardingWizard Livewire component handles this automatically.

Via Filament Admin

System administrators can set tenant locale via the Tenant resource in Filament admin panel (Settings section).

How Locale Resolution Works

The SetTenantLocale middleware determines the locale in this priority order:

  1. Tenant setting - $tenant->settings['locale']
  2. Session preference - session('locale')
  3. Browser detection - Accept-Language header
  4. Application default - config('app.locale')

Using Translations in Code

In Blade Templates

{{-- Using the __() helper --}}
<h1>{{ __('auth.welcome_back') }}</h1>

{{-- With parameters --}}
<p>{{ __('dashboard.welcome', ['name' => $user->name]) }}</p>

{{-- Pluralization --}}
<span>{{ trans_choice('dashboard.minutes_ago', $count) }}</span>

{{-- Using @lang directive --}}
@lang('common.save')

In PHP Code

// Simple translation
$message = __('api.login_success');

// With parameters
$message = __('billing.plan_changed', ['plan' => $planName]);

// In notifications
return (new MailMessage)
    ->subject(__('notifications.subscription_created_subject', [
        'app' => config('app.name'),
    ]))
    ->greeting(__('notifications.subscription_created_greeting'));

In Filament Resources

Filament has its own translation system. For custom labels:

public static function getNavigationLabel(): string
{
    return __('admin.tenants');
}

public static function getModelLabel(): string
{
    return __('admin.tenant');
}

Translation Key Conventions

Follow these conventions for consistent translation keys:

Pattern Example Use Case
{domain}.{key} auth.sign_in Simple labels
{domain}.{key}_title billing.plans_title Page titles
{domain}.{key}_help auth.forgot_password_help Help text
{domain}.{key}_button auth.reset_password_button Button labels
{domain}.{key}_success billing.subscription_created Success messages
{domain}.{key}_error billing.checkout_error Error messages

Email Notifications

All email notifications use translations from lang/en/notifications.php. When adding a new notification:

  1. Add translation keys to notifications.php
  2. Use __() helper in your notification class
  3. Include placeholders for dynamic content

Example:

// In notifications.php
'welcome_subject' => 'Welcome to :app!',
'welcome_line1' => 'Hello :name, thanks for joining us.',

// In Notification class
return (new MailMessage)
    ->subject(__('notifications.welcome_subject', ['app' => config('app.name')]))
    ->line(__('notifications.welcome_line1', ['name' => $user->name]));

API Response Messages

API responses use translations from lang/en/api.php:

return $this->success(
    data: $resource,
    message: __('api.project_created')
);

return $this->error(
    message: __('api.not_found'),
    code: 404
);

Adding Language Selector

To add a language selector to your UI:

<select wire:change="setLocale($event.target.value)">
    @foreach(\App\Http\Middleware\SetTenantLocale::getSupportedLocales() as $code => $name)
        <option value="{{ $code }}" @selected(app()->getLocale() === $code)>
            {{ $name }}
        </option>
    @endforeach
</select>

In your Livewire component:

public function setLocale(string $locale): void
{
    session(['locale' => $locale]);
    $this->redirect(request()->header('Referer'));
}

Date & Number Formatting

For locale-aware date and number formatting:

// Dates (using Carbon)
$date->translatedFormat('F j, Y'); // "January 15, 2025" or "15. ledna 2025"
$date->diffForHumans(); // "2 hours ago" or "před 2 hodinami"

// Numbers
number_format($amount, 2,
    __('common.decimal_separator'),
    __('common.thousands_separator')
);

// Currency (consider using money package)
// or use Intl extension if available
$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
$formatter->formatCurrency($amount, 'USD');

Testing Translations

Check for missing translations

# Find hardcoded strings that should be translated
grep -r "\"[A-Z][a-z]" resources/views --include="*.blade.php" | grep -v "__("

Test with different locales

public function test_displays_in_czech(): void
{
    App::setLocale('cs');

    $response = $this->get('/login');

    $response->assertSee('Přihlásit se');
}

FilamentPHP Translations

Filament has its own language files. To publish and customize:

php artisan vendor:publish --tag=filament-panels-translations
php artisan vendor:publish --tag=filament-forms-translations
php artisan vendor:publish --tag=filament-tables-translations
php artisan vendor:publish --tag=filament-notifications-translations

This creates files in lang/vendor/filament-* that you can customize.

Best Practices

  1. Never hardcode user-facing strings - Always use __() helper
  2. Use descriptive keys - auth.login_button not auth.btn1
  3. Group related translations - Keep billing strings in billing.php
  4. Include placeholders - Use :variable for dynamic content
  5. Test with long strings - German translations are often longer than English
  6. Consider RTL - If supporting Arabic/Hebrew, test RTL layouts
  7. Keep fallback locale - Always have complete English translations as fallback

Troubleshooting

Translations not showing

  1. Check file exists in correct location (lang/{locale}/{file}.php)
  2. Verify locale is in SUPPORTED_LOCALES array
  3. Clear config cache: php artisan config:clear
  4. Check for syntax errors in translation files

Wrong locale being used

  1. Check middleware priority in bootstrap/app.php
  2. Verify tenant settings are being read correctly
  3. Check session is starting before locale middleware runs

Missing pluralization

Ensure you're using trans_choice() for pluralized strings:

// In translation file
'items' => '{0} No items|{1} One item|[2,*] :count items',

// In code
trans_choice('common.items', $count, ['count' => $count]);

Related Documentation