TruCMS translates two different things: the interface (labels, buttons, messages) and the content (pages, posts, services, products). A plugin usually needs both.
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:
| Key | Meaning | Read through |
|---|---|---|
APP_LOCALES | Comma-separated enabled locales (en,ar) | config('localized-routes.supported_locales'), config('translatable.locales') |
APP_LOCALE | The default locale; it has no URL prefix | config('localized-routes.omitted_locale') |
APP_FALLBACK_LOCALE | Used when a string or translation is missing | config('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.
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 stringsThe 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.
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>__() or to a settings label. __('Settings') or __('Pages') returns the whole settings.php or pages.php array, and the screen fails with a type error. Use a namespaced key or a full sentence.__e($key) is a small core helper that returns an empty string for an empty key; use it when the key itself is optional data.messages()), and settings help text.lang/en to lang/{locale} inside your module and translating the values. Keys you leave out fall back to APP_FALLBACK_LOCALE.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.
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.
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']);
});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.
$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 rowFor 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.
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.
/booking), TruCommerce (/shop) and TruPayments (/payments) register plain routes, and the response cache keys their pages without a locale prefix. The visitor's language still comes from their session, so translated strings and content render correctly.Route::localized(), generate links with route() as usual; the package produces the URL for the current locale.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.
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('خدمة رائعة.');
});