mahmoud-abdelhamid1/transloquent

Polymorphic, API-friendly Eloquent translation package for Laravel

Maintainers

Package info

github.com/Mahmoud-Abdelhamid1/Transloquent

pkg:composer/mahmoud-abdelhamid1/transloquent

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-03-29 15:42 UTC

This package is auto-updated.

Last update: 2026-03-29 15:55:27 UTC


README

Polymorphic, API-friendly Eloquent translation package for Laravel.

Latest Version PHP Version Laravel Version License

What it does

Transloquent lets any Eloquent model expose translated fields with zero boilerplate. Translations are stored in a single polymorphic translations table and resolved automatically on every query based on the request's Accept-Language header.

// Your model — that's all you need
class Product extends TranslatableModel
{
    protected $fillable    = ['price'];
    protected array $translatable = ['name', 'description'];
}

// Store — pass locale-keyed arrays directly
Product::create([
    'price' => 99,
    'name'  => ['en' => 'Chair', 'ar' => 'كرسي'],
]);

// Read — transparent, no extra calls
// Request: Accept-Language: ar
$product->name; // "كرسي"

// Search across all locales
Product::whereTranslation('name', 'like', '%chair%')->paginate(10);

Features

  • Polymorphic — attach translations to any model with a single trait
  • Auto-synccreate() and update() handle translations automatically
  • Auto-loading — translations are eager-loaded on every query via a global scope
  • Locale detection — parses Accept-Language header with full q-value support
  • Fallback chain — requested locale → default locale → first available → null
  • Dual return modescurrent (resolved string) or all (locale object) per request
  • Clean serialization — raw model returns work without a Resource class
  • Translation searchwhereTranslation() searches across all locales via subquery
  • Validation ruleTranslatableRule validates locale-keyed input in FormRequests
  • API Resources — base TranslatableResource and TranslatableCollection included

Installation

composer require mahmoud-abdelhamid1/transloquent
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=transloquent-config

Setup

1. Register middleware

Laravel 11+ — bootstrap/app.php

->withMiddleware(function (Middleware $middleware) {
    $middleware->api(append: [
        \MahmoudAbdelhamid\Transloquent\Http\Middleware\SetLocaleMiddleware::class,
        \MahmoudAbdelhamid\Transloquent\Http\Middleware\TranslationModeMiddleware::class,
    ]);
})

Laravel 10 — app/Http/Kernel.php

protected $middlewareGroups = [
    'api' => [
        // ...
        \MahmoudAbdelhamid\Transloquent\Http\Middleware\SetLocaleMiddleware::class,
        \MahmoudAbdelhamid\Transloquent\Http\Middleware\TranslationModeMiddleware::class,
    ],
];

Or apply them selectively using the registered aliases:

Route::middleware(['set.locale', 'translation.mode'])->group(function () {
    // your routes
});

2. Set up your model

Option A — Extend TranslatableModel (recommended)

use MahmoudAbdelhamid\Transloquent\Models\TranslatableModel;

class Product extends TranslatableModel
{
    protected $fillable    = ['price', 'category_id'];
    protected array $translatable = ['name', 'description'];
}

⚠️ Translatable fields must not exist as columns on the model's table. They live exclusively in the translations table. Do not add them to $fillable.

Option B — Use the trait directly

If you already extend a custom base model:

use MahmoudAbdelhamid\Transloquent\Traits\HasTranslations;

class Product extends YourBaseModel
{
    use HasTranslations;

    protected $fillable    = ['price'];
    protected array $translatable = ['name', 'description'];
}

Writing Translations

Automatic — via create() / update()

Pass locale-keyed arrays directly. The model extracts them automatically before the SQL runs.

// Create
Product::create([
    'price' => 99,
    'name'  => [
        'en' => 'Chair',
        'ar' => 'كرسي',
        'fr' => 'Chaise',
    ],
    'description' => [
        'en' => 'A comfortable chair',
        'ar' => 'كرسي مريح',
    ],
]);

// Update — only provided locales are upserted, others stay untouched
$product->update([
    'price' => 120,
    'name'  => ['en' => 'Armchair'],
]);

Manual — fluent API

For more granular control:

// Single field + locale
$product->setTranslation('name', 'fr', 'Fauteuil')->save();

// Multiple fields for one locale
$product->setTranslationsForLocale('en', [
    'name'        => 'Armchair',
    'description' => 'Very comfortable',
])->save();

// Multiple locales at once
$product->setTranslationsArray([
    'en' => ['name' => 'Chair', 'description' => 'Comfortable'],
    'ar' => ['name' => 'كرسي', 'description' => 'مريح'],
])->save();

Reading Translations

Transparent attribute access

Translatable fields resolve automatically to the active locale set by the Accept-Language header:

// Request: Accept-Language: ar
$product->name;        // "كرسي"
$product->description; // "كرسي مريح"

Force a specific locale

$product->getTranslation('name', 'fr'); // "Chaise"

Get all locales for a key

$product->getTranslations('name');
// ['en' => 'Chair', 'ar' => 'كرسي', 'fr' => 'Chaise']

Fallback chain

When the requested locale has no translation:

Requested locale → Default locale (en) → First available locale → null

Auto-loading

Translations are automatically eager-loaded on every query via a global scope. You never need to manually call withTranslations().

// All of these automatically include translations — no extra call needed
Product::all();
Product::find(1);
Product::where('price', '<', 100)->paginate(10);
Product::whereTranslation('name', 'Chair')->get();

Escape hatches

// Load ALL locales (needed for X-Translation-Mode: all)
Product::withAllTranslations()->get();

// Skip translations entirely — useful in jobs or CLI commands
Product::withoutTranslations()->get();

Translation Modes

Control the shape of translatable fields per request using the X-Translation-Mode header.

Header value Behaviour
current (default) Returns the resolved string for the active locale
all Returns a locale-keyed object with every translation

mode=current — for list and show endpoints:

GET /api/products/1
Accept-Language: ar
{
    "id": 1,
    "price": 99,
    "name": "كرسي",
    "description": "كرسي مريح"
}

mode=all — for edit / admin pages:

GET /api/products/1
X-Translation-Mode: all
{
    "id": 1,
    "price": 99,
    "name": { "en": "Chair", "ar": "كرسي", "fr": "Chaise" },
    "description": { "en": "A comfortable chair", "ar": "كرسي مريح" }
}

The default mode when the header is absent is configurable:

// config/transloquent.php
'default_mode' => 'current', // or 'all'

API Resources

TranslatableResource

Extend this base class and spread translationFields() for your translatable fields:

use MahmoudAbdelhamid\Transloquent\Http\Resources\TranslatableResource;

class ProductResource extends TranslatableResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'price'      => $this->price,
            'created_at' => $this->created_at,

            // Resolves to current or all based on X-Translation-Mode
            ...$this->translationFields($request),
        ];
    }
}

Paginated responses

// Option A — inline, no dedicated collection class
return ProductResource::collection(Product::paginate(15));

// Option B — dedicated collection with translation_mode in meta
use MahmoudAbdelhamid\Transloquent\Http\Resources\TranslatableCollection;

class ProductCollection extends TranslatableCollection
{
    public string $collects = ProductResource::class;
}

return new ProductCollection(Product::paginate(15));

Option B adds translation_mode to the meta block:

{
    "data": [...],
    "meta": {
        "current_page": 1,
        "total": 60,
        "translation_mode": "current"
    }
}

Returning without a Resource

toArray() is overridden so raw returns are clean and consistent:

// These all work — no Resource needed, no raw translations array leaked
return response()->json(Product::all());
return response()->json(Product::paginate(10));
return response()->json($product);

⚠️ Raw returns always use mode=current behaviour. Use a Resource for mode=all.

Searching Translated Fields

Use whereTranslation() to filter by translated values. It uses a WHERE EXISTS subquery to avoid duplicate rows.

// Exact match — searches all locales
Product::whereTranslation('name', 'Chair')->get();

// LIKE — searches all locales
Product::whereTranslation('name', 'like', '%chair%')->get();

// Specific locale
Product::whereTranslation('name', 'Chair', locale: 'en')->get();
Product::whereTranslation('name', 'like', '%كرسي%', locale: 'ar')->get();

// Shorthand for specific locale exact match
Product::whereTranslationIn('name', 'ar', 'كرسي')->get();

// Combine freely with other scopes
Product::whereTranslation('name', 'like', '%chair%')
    ->where('price', '<', 150)
    ->orderBy('price')
    ->paginate(10);

Typical search controller

public function index(Request $request)
{
    $query = Product::query();

    if ($request->filled('search')) {
        $query->whereTranslation('name', 'like', '%' . $request->search . '%');
    }

    if ($request->filled('locale')) {
        // Already handled above via the locale: named argument
        // or use whereTranslationIn for a specific locale search
    }

    return ProductResource::collection($query->paginate(10));
}

Validation

Use TranslatableRule in any FormRequest to validate locale-keyed input.

use MahmoudAbdelhamid\Transloquent\Rules\TranslatableRule;

class StoreProductRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'price' => ['required', 'numeric'],

            // Default locale (en) required, at least one locale must be present
            'name' => ['required', new TranslatableRule()],

            // Optional — values may be empty strings
            'description' => ['nullable', new TranslatableRule(nullable: true)],

            // With length constraints
            'slug' => ['required', new TranslatableRule(min: 3, max: 80)],

            // Override the required locale
            'bio' => ['required', new TranslatableRule(requiredLocale: 'ar')],
        ];
    }
}

What it enforces

  • Value must be an array (not a plain string)
  • At least one locale key must be present
  • The required locale (en by default) must always be included
  • Each value must be a non-empty string (unless nullable: true)
  • Unknown locales rejected if allowed_locales whitelist is configured
  • Per-locale min / max length checked with mb_strlen (Unicode-safe)

Constructor parameters

Parameter Type Default Description
nullable bool false Allow null value and empty strings
requiredLocale ?string config default_locale Locale that must always be present
min ?int null Minimum character length per locale value
max ?int null Maximum character length per locale value

Example validation errors

{
    "errors": {
        "name": ["The name must include the \"en\" locale."],
        "slug": ["The value for locale \"ar\" in slug must be at least 3 characters."]
    }
}

Full Controller Example

use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $query = Product::query();

        if ($request->filled('search')) {
            $query->whereTranslation('name', 'like', '%' . $request->search . '%');
        }

        return ProductResource::collection($query->paginate(10));
    }

    public function store(StoreProductRequest $request)
    {
        // Translations are extracted and saved automatically
        $product = Product::create($request->validated());

        return new ProductResource($product);
    }

    public function show(Product $product)
    {
        // Translations already loaded by global scope
        return new ProductResource($product);
    }

    public function update(UpdateProductRequest $request, Product $product)
    {
        // Only provided locales are upserted — others stay untouched
        $product->update($request->validated());

        return new ProductResource($product);
    }

    public function destroy(Product $product)
    {
        // Translations are automatically deleted
        $product->delete();

        return response()->noContent();
    }
}

Helper Function

// Uses the active app locale
translatable($product, 'name');

// Force a specific locale
translatable($product, 'name', 'ar');

Configuration

// config/transloquent.php
return [
    // First fallback locale when the requested locale has no translation
    'default_locale'  => 'en',

    // Locale whitelist — empty array = allow all
    'allowed_locales' => [],

    // HTTP header used for locale detection
    'locale_header'   => 'Accept-Language',

    // Database table name
    'table'           => 'translations',

    // Default return mode when X-Translation-Mode header is absent
    // 'current' → resolved string  |  'all' → locale-keyed object
    'default_mode'    => 'current',

    // HTTP header used to switch translation mode per request
    'mode_header'     => 'X-Translation-Mode',
];

Database Schema

translations
────────────────────────────────────────────────────
id                  bigint       PK
translatable_id     bigint       ─┐  polymorphic
translatable_type   string       ─┘
locale              string(10)   e.g. "en", "ar"
key                 string       e.g. "name"
value               text
created_at
updated_at

UNIQUE (translatable_id, translatable_type, locale, key)

API Reference

HasTranslations — public methods

Method Returns Description
getTranslation($key, $locale) ?string Get translation for a key in a given locale
getTranslations($key) array Get all locales for a key
setTranslation($key, $locale, $value) static Stage a single translation
setTranslationsForLocale($locale, $array) static Stage multiple keys for one locale
setTranslationsArray($array) static Stage multiple locales at once
getTranslatableAttributes() array Returns $translatable array
isTranslatableAttribute($key) bool Check if a key is translatable

Scopes

Scope Description
whereTranslation($key, $op, $value, $locale) Filter by translated field value
whereTranslationIn($key, $locale, $value) Filter by translated field in a specific locale
withAllTranslations() Eager-load all locales (bypasses global scope filter)
withoutTranslations() Disable auto-loading entirely for this query

Middleware aliases

Alias Class Description
set.locale SetLocaleMiddleware Parses Accept-Language, calls App::setLocale()
translation.mode TranslationModeMiddleware Reads X-Translation-Mode, stores on $request

License

MIT — Mahmoud Abdelhamid