mahmoud-abdelhamid1 / transloquent
Polymorphic, API-friendly Eloquent translation package for Laravel
Package info
github.com/Mahmoud-Abdelhamid1/Transloquent
pkg:composer/mahmoud-abdelhamid1/transloquent
Requires
- php: ^8.1|^8.2|^8.3
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
README
Polymorphic, API-friendly Eloquent translation package for Laravel.
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-sync —
create()andupdate()handle translations automatically - Auto-loading — translations are eager-loaded on every query via a global scope
- Locale detection — parses
Accept-Languageheader with full q-value support - Fallback chain — requested locale → default locale → first available →
null - Dual return modes —
current(resolved string) orall(locale object) per request - Clean serialization — raw model returns work without a Resource class
- Translation search —
whereTranslation()searches across all locales via subquery - Validation rule —
TranslatableRulevalidates locale-keyed input in FormRequests - API Resources — base
TranslatableResourceandTranslatableCollectionincluded
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
translationstable. 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=currentbehaviour. Use a Resource formode=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 (
enby default) must always be included - Each value must be a non-empty string (unless
nullable: true) - Unknown locales rejected if
allowed_localeswhitelist is configured - Per-locale
min/maxlength checked withmb_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