Skip to content
SaaS4Builders
Internationalization

Backend Translations

Laravel translation files, model translations with Astrotomic Translatable, validation of translatable input, and how to add new translation keys or locales.

The backend translation system has two parts: PHP translation files for system messages (API errors, emails, PDFs) and database-driven model translations for catalog content (product names, plan descriptions). Both use Laravel's locale system, with the active locale set automatically by the SetLocaleFromHeader middleware on every request.


Translation File Structure

Translation files live in backend/lang/, organized by locale and domain:

backend/lang/
├── en/
│   ├── admin.php
│   ├── auth.php
│   ├── billing.php
│   ├── catalog.php
│   ├── impersonation.php
│   ├── mail.php
│   ├── notifications.php
│   ├── onboarding.php
│   ├── passwords.php
│   ├── profile.php
│   ├── push.php
│   ├── settings.php
│   ├── team.php
│   ├── tenants.php
│   ├── usage.php
│   └── validation.php
├── es/
│   └── (same 16 files)
├── fr/
│   └── (same 16 files)
└── it/
    └── (same 16 files)

Conventions:

  • One file per domain — billing.php for billing messages, team.php for team messages
  • 16 domain files × 4 locales = 64 translation files total
  • All files use declare(strict_types=1)
  • Keys are snake_case, descriptive, and grouped by context

Translation File Format

Each file returns a PHP array of key-value pairs. Keys can be flat or nested:

backend/lang/en/team.php
<?php

declare(strict_types=1);

return [
    'roles' => [
        'owner' => 'Owner',
        'admin' => 'Administrator',
        'member' => 'Member',
    ],

    'email' => [
        'invitation_subject' => "You're invited to join :tenant",
        'invitation_greeting' => ':inviter has invited you to join :tenant.',
        'invitation_role' => 'You will be added as a :role.',
        'accept_button' => 'Accept Invitation',
        'invitation_expiry' => 'This invitation will expire on :date.',
    ],

    // API responses
    'invitation_sent' => 'Invitation sent successfully.',
    'invitation_accepted' => 'You have joined the team.',
    'member_removed' => 'Member has been removed from the team.',
    'role_changed' => 'Member role has been updated.',

    // Domain exceptions
    'exceptions' => [
        'already_invited' => 'An invitation is already pending for :email.',
        'seat_limit_reached' => 'Seat limit reached: :current/:max seats used.',
        'cannot_remove_owner' => 'Cannot remove the team owner.',
    ],
];

Key patterns:

PatternExampleUsage
Flat key'invitation_sent' => '...'Simple messages
Nested array'roles' => ['owner' => 'Owner']Grouped related strings
Placeholder':tenant', ':email', ':current'Dynamic values substituted at runtime

Using Translations in Code

Laravel provides two functions for translating strings:

// Simple translation
$message = __('team.invitation_sent');
// → "Invitation sent successfully."

// With placeholders
$message = __('team.exceptions.seat_limit_reached', [
    'current' => $currentSeats,
    'max' => $maxSeats,
]);
// → "Seat limit reached: 5/10 seats used."

// Nested keys use dot notation
$roleName = __('team.roles.admin');
// → "Administrator"

// Pluralization
$message = trans_choice('usage.events_recorded', $count, ['count' => $count]);

The __() helper automatically uses the locale set by the SetLocaleFromHeader middleware, so API responses are returned in the language the client requested.

Only user-facing strings are translated: API error messages, email content, PDF labels, and notification text. Internal logs, technical exceptions, and configuration keys are never translated.

Model Translations (Astrotomic Translatable)

Catalog data — products, plans, and features — needs to be translatable at runtime because it is managed through admin interfaces. The astrotomic/laravel-translatable package handles this by storing translations in separate database tables.

Translatable Models

ModelFileTranslatable Fields
Featurebackend/app/Models/Feature.phpname, description
Productbackend/app/Models/Product.phpname, description
Planbackend/app/Models/Plan.phpname, description

How It Works

A translatable model implements TranslatableContract, uses the Translatable trait, and declares which attributes are translated:

backend/app/Models/Feature.php
final class Feature extends Model implements TranslatableContract
{
    use HasFactory;
    use HasUuids;
    use SearchesTranslations;
    use Translatable;

    /**
     * @var list<string>
     */
    public $translatedAttributes = ['name', 'description'];

    protected $fillable = [
        'code',
        'is_active',
        'is_system',
        'metadata',
    ];
}

Notice that name and description are not in $fillable — they are managed by the translation system, not the main model.

Translation Tables

Each translatable model has a corresponding *_translations table:

// Migration example
Schema::create('feature_translations', function (Blueprint $table): void {
    $table->id();
    $table->foreignUuid('feature_id')->constrained()->cascadeOnDelete();
    $table->string('locale')->index();
    $table->string('name');
    $table->text('description')->nullable();
    $table->unique(['feature_id', 'locale']);
});

The unique(['feature_id', 'locale']) constraint ensures one translation row per model per locale.

Translation Models

Each translation table has a minimal Eloquent model:

backend/app/Models/Translations/FeatureTranslation.php
final class FeatureTranslation extends Model
{
    public $timestamps = false;

    protected $fillable = ['name', 'description'];
}

Translation models have no timestamps — they are lightweight value containers.

Reading Translated Values

Accessing a translated attribute automatically returns the value for the current locale:

// Returns the name in the current locale (set by middleware)
$feature->name;

// Get a specific locale
$feature->translate('fr')->name;

// Get all translations
$feature->translations; // Collection of FeatureTranslation models

The Translatable package is configured with fallback behavior — if a translation is missing for the requested locale, it falls back to English:

backend/config/translatable.php
'use_fallback' => true,
'use_property_fallback' => true,
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),

Searching Translated Fields

The SearchesTranslations trait provides case-insensitive search across translated fields:

// Case-insensitive search on translated name
Feature::whereTranslationILike('name', '%analytics%')->get();

This uses ILIKE on PostgreSQL for efficient case-insensitive matching across translation tables.


Translatable Configuration

The Translatable package is configured in backend/config/translatable.php:

SettingValuePurpose
locales['en', 'fr', 'es', 'it']Available locales (from APP_SUPPORTED_LOCALES env)
use_fallbacktrueFall back to another locale if translation missing
fallback_locale'en'Which locale to fall back to
use_property_fallbacktrueFall back per field (not just per model)
translation_model_namespace'App\Models\Translations'Where translation models live
to_array_always_loads_translationsfalsePerformance: don't auto-load translations on toArray()

Validating Translatable Input

When creating or updating translatable models through the API, the HasTranslatableRules trait generates validation rules that enforce translations per locale:

backend/app/Http/Requests/Concerns/HasTranslatableRules.php
protected function translatableRules(array $fields, bool $required = true): array
{
    $rules = [];
    $defaultLocale = config('translatable.fallback_locale', 'en');
    $locales = config('translatable.locales', ['en']);

    foreach ($fields as $field => $baseRules) {
        $rules[$field] = $required ? ['required', 'array'] : ['sometimes', 'array'];

        foreach ($locales as $locale) {
            $localeRules = $baseRules;

            if ($locale === $defaultLocale && $required) {
                array_unshift($localeRules, 'required');
            } else {
                array_unshift($localeRules, 'nullable');
            }

            $rules["{$field}.{$locale}"] = $localeRules;
        }
    }

    return $rules;
}

This generates rules where the default locale is required and other locales are optional:

// Generated rules for 'name' field:
'name'    => ['required', 'array'],
'name.en' => ['required', 'string', 'max:255'],   // English required
'name.fr' => ['nullable', 'string', 'max:255'],   // Others optional
'name.es' => ['nullable', 'string', 'max:255'],
'name.it' => ['nullable', 'string', 'max:255'],

Use it in FormRequest classes:

backend/app/Http/Requests/Api/V1/Admin/Catalog/StoreFeatureRequest.php
public function rules(): array
{
    return array_merge(
        $this->translatableRules([
            'name' => ['string', 'max:255'],
        ]),
        $this->translatableRulesOptional([
            'description' => ['string', 'max:65535'],
        ]),
        [
            'code' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:features,code'],
            'is_active' => ['sometimes', 'boolean'],
            'metadata' => ['nullable', 'array'],
        ]
    );
}

Notice that name uses translatableRules() (English required) while description uses translatableRulesOptional() (all locales optional).

The API expects translatable fields as nested objects:

{
  "code": "analytics",
  "name": {
    "en": "Advanced Analytics",
    "fr": "Analytique avancée",
    "es": "Analítica avanzada",
    "it": "Analitica avanzata"
  },
  "description": {
    "en": "Real-time dashboard and reporting tools"
  }
}

The getTranslations() helper extracts non-null translations from validated data:

$translations = $request->getTranslations('name');
// ['en' => 'Advanced Analytics', 'fr' => 'Analytique avancée', ...]

How to Add a New Translation Key

Step 1: Identify the domain file. If your feature is billing-related, use billing.php. For a new domain, see the next section.

Step 2: Add the key to all four locale files:

return [
    // ... existing keys ...
    'refund_processed' => 'Refund of :amount has been processed.',
];

Step 3: Use the key in your code:

$message = __('billing.refund_processed', ['amount' => '$29.99']);
Always add translations for all four locales when creating a new key. Missing translations will fall back to the key name (e.g., billing.refund_processed), which is not user-friendly.

How to Add a New Domain File

If your feature doesn't fit into an existing domain file, create a new one:

Step 1: Create the PHP file in all four locale directories:

backend/lang/en/reports.php
<?php

declare(strict_types=1);

return [
    'generated' => 'Report generated successfully.',
    'export_failed' => 'Failed to export report: :reason.',
];

Repeat for backend/lang/fr/reports.php, backend/lang/es/reports.php, and backend/lang/it/reports.php with translated values.

Step 2: Use it with the new domain prefix:

$message = __('reports.generated');

No registration or configuration is needed — Laravel auto-discovers translation files by directory convention.


How to Add a 5th Locale

Adding a new locale (e.g., German de) requires changes in both the backend and frontend:

Backend Steps

1. Update the environment variable:

APP_SUPPORTED_LOCALES=en,fr,es,it,de

2. Create the locale directory with all domain files:

mkdir backend/lang/de

Copy all 16 files from backend/lang/en/ to backend/lang/de/ and translate the values.

3. Update the Translatable config — the locales key reads from the same env variable, so it picks up de automatically. Verify in backend/config/translatable.php:

'locales' => explode(',', env('APP_SUPPORTED_LOCALES', 'en,fr,es,it')),

4. Add translation rows for existing catalog data (products, plans, features) in the database. You can do this via a migration or through the admin API.

Frontend Steps

See Frontend i18n — How to Add a 5th Locale for the corresponding frontend changes.


What's Next