Localization and Translatable Models

TruCMS translates two different things: the interface (labels, buttons, messages) and the content (pages, posts, services, products). A plugin usually needs both.

How languages are switched on

Site owners manage languages in the admin (Languages in the sidebar, permission manage languages). When a language is enabled, disabled or made default, TruCMS writes the list to .env:

KeyMeaningRead through
APP_LOCALESComma-separated enabled locales (en,ar)config('localized-routes.supported_locales'), config('translatable.locales')
APP_LOCALEThe default locale; it has no URL prefixconfig('localized-routes.omitted_locale')
APP_FALLBACK_LOCALEUsed when a string or translation is missingconfig('app.fallback_locale')

Your plugin never edits these. Read the enabled languages with App\Models\Language::getLocales() (cached) and the current one with Language::getCurrent(); Language::getCurrentDirection() returns ltr or rtl.

Interface strings

Where they live

lang/en/admin.php, lang/en/settings.php ...     core, used as __('settings.cache')
lang/en.json                                    core JSON strings
Modules/Testimonials/lang/en/testimonials.php  your plugin, used as __('testimonials::testimonials.menu')
lang/modules/testimonials/en/testimonials.php  optional site-level override of your plugin's strings

The core ships English plus Arabic, German, Spanish, French, Hindi, Portuguese, Russian and Turkish. Bundled modules ship English; other languages come from the site's own translations.

Namespace your strings

The base module provider loads lang/without a namespace, which would make your keys collide with core files. Every bundled module overrides registerTranslations() so strings are reachable as alias::file.key and a site can override them in lang/modules/{alias}. Copy that override; it is shown in The service provider.

Modules/Testimonials/lang/en/testimonials.php

return [
    'menu' => 'Testimonials',
    'saved' => 'Testimonial saved.',
    'count' => '{0} No testimonials|{1} One testimonial|[2,*] :count testimonials',
];
<h2>{{ __('testimonials::testimonials.menu') }}</h2>
<p>{{ trans_choice('testimonials::testimonials.count', $total) }}</p>

Rules that save you bugs

The Translations screen

The admin Languages screen is a database-backed editor (route names start with ltu.). It works on the core lang/ files; import new keys with php artisan translations:import and write edits back to files with php artisan translations:export. Two helper commands find gaps: dotartisan:translations:missing (keys used in code but not defined) and dotartisan:translations:unused.

Translatable content

Content that editors write per language (titles, descriptions, slugs, SEO fields) is stored in a separate {model}_translations table, one row per locale. Pages, posts, categories, tags, FAQs, Booking services, packages, locations and most TruCommerce catalogue records work this way.

1. Migrations

database/migrations/xxxx_create_testimonial_translations_table.php

Schema::create('testimonial_translations', function (Blueprint $table): void {
    $table->id();
    $table->foreignId('testimonial_id')->constrained()->cascadeOnDelete();
    $table->string('locale', 10)->index();
    $table->text('quote');
    $table->string('author_role')->nullable();

    $table->unique(['testimonial_id', 'locale']);
});

2. Models

Modules/Testimonials/app/Models/Testimonial.php

use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract;
use Astrotomic\Translatable\Translatable;

class Testimonial extends BaseModel implements TranslatableContract
{
    use Translatable;

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

    public string $translationModel = TestimonialTranslation::class;   // required in a module

    protected $fillable = ['author_name', 'rating', 'is_published'];
}

// Modules/Testimonials/app/Models/Translations/TestimonialTranslation.php
class TestimonialTranslation extends Model
{
    public $timestamps = false;

    protected $fillable = ['quote', 'author_role'];
}

Always set $translationModel

config/translatable.php points the default lookup at App\Models\Translations\{Model}Translation, which suits core models only. A module model must name its translation class explicitly, as Booking's Service does with public string $translationModel = ServiceTranslation::class;. Without it the first translated read fails with a class-not-found error.

3. Reading and writing

$testimonial->quote;                         // current locale, falls back to APP_FALLBACK_LOCALE
$testimonial->translate('ar')?->quote;       // one locale

$testimonial->fill([
    'author_name' => 'Ada',
    'en' => ['quote' => 'Wonderful service.'],
    'ar' => ['quote' => 'خدمة رائعة.'],
])->save();

Testimonial::query()->with('translations')->get();   // avoid one query per row

For the admin form, loop over Language::getLocales() and render one tab per locale, with input names like en[quote]. The core page and post forms are good references.

Localized URLs

Core public routes are wrapped in Route::localized(): the default locale has no prefix (/about) and other locales do (/ar/about). The locale is detected from the URL, then the signed-in user, session, cookie and browser.

Right-to-left languages

Layouts receive $appDir (ltr or rtl) from the theme middleware and set the document direction. Echo ships an RTL stylesheet layer (assets/sass/_rtl.scss). In your own CSS, use logical properties (margin-inline-start, padding-inline-end, text-align: start) and Bootstrap's ms-*/me-* utilities instead of left/right, and your screens work in both directions.

Testing translations

it('shows the Arabic quote', function (): void {
    seedTestLocales(['en', 'ar']);                 // helper from tests/Pest.php

    $testimonial = Testimonial::factory()->create();
    $testimonial->translateOrNew('ar')->quote = 'خدمة رائعة.';
    $testimonial->save();

    app()->setLocale('ar');

    expect($testimonial->fresh()->quote)->toBe('خدمة رائعة.');
});