Skip to content

Latest commit

 

History

History
667 lines (491 loc) · 18 KB

File metadata and controls

667 lines (491 loc) · 18 KB

Localization (i18n)

This project handles two distinct localization concerns:

  1. UI strings — buttons, validation messages, email subjects, error codes. Lives in lang/ files, accessed via __() / trans(). Laravel built-in.
  2. 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.


Part 1 — UI Strings (Laravel built-in)

For static app text: buttons, errors, validation, email subjects, enum descriptions.

Where Translations Live

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.


Key Naming

  • Use snake_case for keys.
  • Use dot notation for hierarchy: orders.confirmation.subject.
  • Keys describe purpose, not the English text. mail.welcome.subject, not mail.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);

Placeholders

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!',

Pluralization

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]);

Setting the Locale

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));

Fallback 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.


Enums + Localization

See enums-bensampo.md. The LocalizedEnum contract routes $status->description through lang/{locale}/enums.php.


Blade

<h1>{{ __('orders.list.title') }}</h1>

@lang('orders.show.cancel_confirm')

{{ trans_choice('orders.list.count', $orders->count(), ['count' => $orders->count()]) }}

API Error Responses

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).


Part 2 — Model Attribute Translations (spatie/laravel-translatable)

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


Setup

composer require spatie/laravel-translatable

No 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,
];

Define a Translatable Model

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 $translatable as 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.

Migration — JSON Columns

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": "حذاء رياضي" }

Reading Translations

$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

Writing Translations

// 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',
]);

Fallback Behavior

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.


Form Request Validation

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.


Querying Translated Fields

Exact match in a specific locale

Product::where('name->en', 'Sneakers')->get();
Product::where('name->fr', 'Baskets')->get();

LIKE search in a specific locale

Product::where('name->en', 'LIKE', '%sneaker%')->get();

Ordering by translated value

Product::orderBy('name->en')->get();

Search across all locales (MySQL 5.7+ / PostgreSQL)

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).


Search & Indexing — use Scout

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 tsvector per 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.


API Resources — Two Strategies

Pick the right one per endpoint.

Strategy A — Single locale (public read endpoints)

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,
        ];
    }
}

Strategy B — All locales (admin / content management)

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.


Translatable Slugs

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.


Caching Translated Models

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();

Default-Locale-Only Mode

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;

Part 3 — When to Use Which

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

Anti-Patterns

UI strings (Laravel built-in)

  • ❌ 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.

Model translations (spatie/laravel-translatable)

  • ❌ 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 string when it should be array of 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 LIKE over 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_translations table — that's the legacy pattern this package replaces.
  • ❌ Setting $translatable outside 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.