Backend Translations
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.phpfor billing messages,team.phpfor 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:
<?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:
| Pattern | Example | Usage |
|---|---|---|
| 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.
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
| Model | File | Translatable Fields |
|---|---|---|
| Feature | backend/app/Models/Feature.php | name, description |
| Product | backend/app/Models/Product.php | name, description |
| Plan | backend/app/Models/Plan.php | name, description |
How It Works
A translatable model implements TranslatableContract, uses the Translatable trait, and declares which attributes are translated:
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:
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:
'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:
| Setting | Value | Purpose |
|---|---|---|
locales | ['en', 'fr', 'es', 'it'] | Available locales (from APP_SUPPORTED_LOCALES env) |
use_fallback | true | Fall back to another locale if translation missing |
fallback_locale | 'en' | Which locale to fall back to |
use_property_fallback | true | Fall back per field (not just per model) |
translation_model_namespace | 'App\Models\Translations' | Where translation models live |
to_array_always_loads_translations | false | Performance: 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:
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:
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.',
];
return [
// ... existing keys ...
'refund_processed' => 'Remboursement de :amount effectué.',
];
return [
// ... existing keys ...
'refund_processed' => 'Reembolso de :amount procesado.',
];
return [
// ... existing keys ...
'refund_processed' => 'Rimborso di :amount elaborato.',
];
Step 3: Use the key in your code:
$message = __('billing.refund_processed', ['amount' => '$29.99']);
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:
<?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
- Frontend i18n — Nuxt i18n module setup, JSON locale files, Zod validation integration, and currency formatting
- Internationalization Overview — Architecture summary and locale detection
Internationalization Overview
How SaaS4Builders handles multi-language support: dual-layer translation architecture, included locales, adding or removing languages, locale detection, and what gets translated.
Frontend i18n
Nuxt i18n module setup, JSON locale files, translation usage in components, Zod validation integration, locale-aware currency formatting, and how to extend the system.