This project handles two distinct localization concerns:
- UI strings — buttons, validation messages, email subjects, error codes. Lives in
lang/files, accessed via__()/trans(). Laravel built-in. - Model attribute translations — product names, article bodies, category titles stored per-record in the database. Uses
spatie/laravel-translatable(v6).
Use both together. UI strings ≠ DB content.
Loaded when working on user-facing strings, translations, or multi-language features. Called from agent-instructions.md routing table.
For static app text: buttons, errors, validation, email subjects, enum descriptions.
lang/
├── en/
│ ├── auth.php
│ ├── validation.php
│ ├── pagination.php
│ ├── passwords.php
│ ├── mail.php # subjects + body fragments for Mailables
│ ├── notifications.php # in-app + database notification copy
│ ├── enums.php # bensampo enum descriptions
│ └── {domain}.php # e.g., orders.php, billing.php
├── ar/
└── ...
Use PHP arrays for short keys; use JSON files (lang/en.json) only for full-sentence translations where the English string is the key.
- Use snake_case for keys.
- Use dot notation for hierarchy:
orders.confirmation.subject. - Keys describe purpose, not the English text.
mail.welcome.subject, notmail.welcome_to_our_app. - Group by feature/domain — don't dump everything in
messages.php.
// lang/en/orders.php
return [
'list' => [
'title' => 'Your orders',
'empty' => 'You have not placed any orders yet.',
],
'show' => [
'title' => 'Order :number',
'cancel_button' => 'Cancel order',
'cancel_confirm' => 'Are you sure you want to cancel this order?',
],
'status' => [
'pending' => 'Pending',
'processing' => 'Processing',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
],
];Access:
__('orders.show.title', ['number' => $order->number]);
trans_choice('orders.list.count', $count);Use named placeholders (:name), not positional. Match case in the placeholder to match case in output:
| Placeholder | Result |
|---|---|
:name |
john |
:Name |
John |
:NAME |
JOHN |
'welcome' => 'Welcome, :Name!',Use trans_choice with | separators:
// lang/en/orders.php
'list' => [
'count' => '{0} No orders|{1} :count order|[2,*] :count orders',
],trans_choice('orders.list.count', $count, ['count' => $count]);Per-request (middleware):
final class SetLocale
{
public function handle(Request $request, Closure $next): mixed
{
$locale = $request->user()?->locale
?? $request->header('Accept-Language')
?? config('app.fallback_locale');
if (! in_array($locale, config('app.supported_locales', ['en']), true)) {
$locale = config('app.fallback_locale');
}
App::setLocale($locale);
return $next($request);
}
}For queued jobs / notifications:
$user->notify((new OrderShipped($order))->locale($user->locale));Set in config/app.php:
'locale' => 'en',
'fallback_locale' => 'en',
'supported_locales' => ['en', 'fr', 'ar', 'de'],Missing keys fall back automatically. Never display raw keys (orders.show.title) to end users — Laravel does this by default if both locales lack the key, which is a translation bug to fix.
See enums-bensampo.md. The LocalizedEnum contract routes $status->description through lang/{locale}/enums.php.
<h1>{{ __('orders.list.title') }}</h1>
@lang('orders.show.cancel_confirm')
{{ trans_choice('orders.list.count', $orders->count(), ['count' => $orders->count()]) }}For API messages, prefer translation keys in error codes, not localized strings — let the client localize.
{
"message": "Insufficient balance to complete this transaction.",
"code": "INSUFFICIENT_BALANCE",
"available": 12.50,
"required": 25.00
}If the API serves multiple locales directly, set the locale from Accept-Language header (validate against supported locales).
For content stored in the database that needs translations per record: product names, article bodies, category titles, FAQ entries, page content.
Reference: https://spatie.be/docs/laravel-translatable/v6/introduction
composer require spatie/laravel-translatableNo package migration needed — translations live in JSON columns on your existing tables.
Publish config (optional):
php artisan vendor:publish --tag=translatable// config/translatable.php
return [
'fallback_locale' => null, // null = use app.fallback_locale
'fallback_any' => true, // fall back to any available translation if fallback locale missing
'fallback_to_default_locale' => false,
];use Spatie\Translatable\HasTranslations;
final class Product extends Model
{
use HasTranslations;
/** @var array<int, string> */
public array $translatable = ['name', 'description', 'seo_title', 'seo_description'];
protected $fillable = ['name', 'description', 'seo_title', 'seo_description', 'price_minor', 'currency', 'sku'];
protected $casts = [
'price_minor' => 'integer',
];
}Rules:
- Declare
$translatableas a typed array of column names. - Translatable columns must be JSON in the migration.
- Non-translatable columns (price, SKU, foreign keys) stay as normal types.
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('description');
$table->json('seo_title')->nullable();
$table->json('seo_description')->nullable();
$table->bigInteger('price_minor');
$table->string('currency', 3);
$table->string('sku')->unique();
$table->timestamps();
});In the database, each row stores:
{ "en": "Sneakers", "fr": "Baskets", "ar": "حذاء رياضي" }$product = Product::find(1);
// Current locale (App::getLocale())
$product->name; // "Sneakers" when locale = 'en'
// Specific locale
$product->getTranslation('name', 'fr'); // "Baskets"
// With fallback control
$product->getTranslation('name', 'de', useFallbackLocale: false); // "" if 'de' missing
// All translations for one attribute
$product->getTranslations('name'); // ['en' => 'Sneakers', 'fr' => 'Baskets', 'ar' => '...']
// All translations for all translatable attributes
$product->getTranslations(); // ['name' => [...], 'description' => [...]]
// Available locales for this record
$product->getTranslatedLocales('name'); // ['en', 'fr', 'ar']
// Check existence
$product->hasTranslation('name', 'de'); // false// Set current-locale translation
$product->name = 'Sneakers';
// Set specific locale
$product->setTranslation('name', 'fr', 'Baskets');
// Set many locales for one attribute at once
$product->setTranslations('name', [
'en' => 'Sneakers',
'fr' => 'Baskets',
'ar' => 'حذاء رياضي',
]);
// Remove a translation
$product->forgetTranslation('name', 'de');
// Remove all translations of one attribute
$product->forgetAllTranslations('description');
$product->save();Mass assignment (creating a record) works directly with an associative array:
Product::create([
'name' => [
'en' => 'Sneakers',
'fr' => 'Baskets',
],
'description' => [
'en' => 'Lightweight running shoes.',
'fr' => 'Chaussures de course légères.',
],
'price_minor' => 9900,
'currency' => 'USD',
'sku' => 'SNK-001',
]);Configured in config/translatable.php. The cascade:
Requested locale → fallback_locale → fallback_any → empty string
| Setting | Behavior when 'de' is requested but missing |
|---|---|
fallback_locale = 'en', fallback_any = false |
Returns en translation. Empty string if en also missing. |
fallback_locale = 'en', fallback_any = true |
Returns en. If en missing, returns the first available translation. |
fallback_locale = null, fallback_any = false |
Returns empty string. |
Recommendation: fallback_locale = 'en', fallback_any = true — guarantees the UI never shows empty fields.
Validate that translatable inputs are arrays keyed by supported locales.
final class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('products.create');
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'array'],
'name.en' => ['required', 'string', 'max:255'],
'name.fr' => ['nullable', 'string', 'max:255'],
'name.ar' => ['nullable', 'string', 'max:255'],
'description' => ['required', 'array'],
'description.en' => ['required', 'string', 'max:5000'],
'description.fr' => ['nullable', 'string', 'max:5000'],
'description.ar' => ['nullable', 'string', 'max:5000'],
'price_minor' => ['required', 'integer', 'min:0'],
'currency' => ['required', 'string', 'size:3', 'in:USD,EUR,GBP'],
'sku' => ['required', 'string', 'max:64', 'unique:products,sku'],
];
}
}For dynamic locale support:
public function rules(): array
{
return [
'name' => ['required', 'array'],
'name.*' => ['string', 'max:255'],
"name.{$this->primaryLocale()}" => ['required', 'string', 'max:255'],
];
}
private function primaryLocale(): string
{
return config('app.fallback_locale');
}Always require at least the fallback locale to be present.
Product::where('name->en', 'Sneakers')->get();
Product::where('name->fr', 'Baskets')->get();Product::where('name->en', 'LIKE', '%sneaker%')->get();Product::orderBy('name->en')->get();Product::whereRaw('JSON_SEARCH(LOWER(name), "one", ?) IS NOT NULL', ['%sneaker%'])->get();For non-trivial multi-locale search, don't roll your own — use Laravel Scout (see below).
Raw JSON queries are slow and fragile. For real search:
- Laravel Scout + Meilisearch (recommended — handles per-language tokenization, stemming, typo tolerance).
- Algolia with multi-language indexes.
- PostgreSQL with
tsvectorper locale.
final class Product extends Model
{
use HasTranslations, Searchable;
public array $translatable = ['name', 'description'];
/** @return array<string, mixed> */
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name_en' => $this->getTranslation('name', 'en', useFallbackLocale: true),
'name_fr' => $this->getTranslation('name', 'fr', useFallbackLocale: true),
'name_ar' => $this->getTranslation('name', 'ar', useFallbackLocale: true),
'description_en' => $this->getTranslation('description', 'en'),
'sku' => $this->sku,
];
}
}Configure Meilisearch with per-language searchable attributes.
Pick the right one per endpoint.
Return only the current locale's value. Clients pass Accept-Language.
final class ProductResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name, // current locale only
'description' => $this->description,
'price' => $this->price_minor / 100,
'currency' => $this->currency,
'sku' => $this->sku,
];
}
}Return the full translation map so admins can edit any locale.
final class ProductAdminResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->getTranslations('name'),
'description' => $this->getTranslations('description'),
'price_minor' => $this->price_minor,
'currency' => $this->currency,
'sku' => $this->sku,
'translated_locales' => $this->getTranslatedLocales('name'),
];
}
}Convention: separate ProductResource (public) from ProductAdminResource (admin) — never mix.
Use spatie/laravel-sluggable together with HasTranslations for per-locale slugs.
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Spatie\Translatable\HasTranslations;
final class Article extends Model
{
use HasTranslations, HasSlug;
public array $translatable = ['title', 'slug', 'body'];
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('title')
->saveSlugsTo('slug');
}
}Route binding by slug:
Route::get('/articles/{article:slug}', ShowArticleController::class);When resolving {article:slug}, the package matches against the current locale's slug.
If you cache content (Cache::remember), always include the locale in the cache key — otherwise switching locale serves stale content.
Cache::remember(
key: "products:{$id}:locale:" . App::getLocale(),
ttl: 3600,
callback: fn () => Product::find($id),
);When the model changes, invalidate all locale variants:
final class ProductObserver
{
public function saved(Product $product): void
{
foreach (config('app.supported_locales') as $locale) {
Cache::forget("products:{$product->id}:locale:{$locale}");
}
}
}Or use cache tags:
Cache::tags(["product:{$id}"])->remember($key, 3600, fn () => /* ... */);
// Invalidate all locales at once:
Cache::tags(["product:{$id}"])->flush();If a record sometimes shouldn't fall back (e.g., legal documents that must show the exact locale or nothing), bypass fallback:
$translation = $product->getTranslationWithoutFallback('description', 'de');Or per-request:
$product->useFallbackLocale = false;| Type of content | Use |
|---|---|
| Button label, error message, validation message, email subject | Laravel built-in (__() + lang/) |
| Product name, article body, category title, page CMS content | spatie/laravel-translatable |
| Enum description ("Pending", "Cancelled") | Laravel built-in via LocalizedEnum |
| Notification email template | Laravel built-in (Markdown view + lang/) |
| Notification email body parameters (product name) | spatie/laravel-translatable (interpolate into template) |
| SEO meta title / description per-page | spatie/laravel-translatable |
| Static "About us" page text | spatie/laravel-translatable if editable from admin; Laravel built-in if dev-managed |
- ❌ Hardcoded English strings in code or Blade — wrap in
__(). - ❌ Translation keys named after English text (
mail.welcome_to_our_great_service). - ❌ Putting all translations in one giant
messages.php. - ❌ Concatenating translated fragments (
__('a') . ' ' . __('b')) — translate full sentences with placeholders. - ❌ Forgetting to localize email subjects, button labels, validation attribute names.
- ❌ Using positional placeholders (
:0,:1) — use named. - ❌ Localizing log messages or internal error codes.
- ❌ Returning machine-readable error codes that are already localized strings.
- ❌ One column per language (
name_en,name_fr,name_ar) — use a single JSON column +HasTranslations. - ❌ Mixing string and JSON columns for translatable fields on the same model — pick one strategy.
- ❌ Validating a translatable field as
stringwhen it should bearrayof locales. - ❌ Returning all locales in public API responses by default — use the single-locale Resource.
- ❌ Returning only current locale in admin API responses — admins need to edit every locale.
- ❌ Querying translated content with
LIKEover the raw JSON — use JSON operators or Scout. - ❌ Caching translatable models without locale in the cache key → stale content on locale switch.
- ❌ Forgetting
fallback_locale→ empty fields leak to UI. - ❌ Letting users create translations in arbitrary languages — gate by
config('app.supported_locales'). - ❌ Storing translations in a separate
product_translationstable — that's the legacy pattern this package replaces. - ❌ Setting
$translatableoutside the model definition (e.g., runtime) — declare statically. - ❌ Forgetting to require at least the fallback locale in Form Requests.
- ❌ Mass-assigning a string when the column is translatable:
Product::create(['name' => 'Sneakers', ...])works for the current locale only — be explicit with the array form for multi-locale creates.