mosamy / translatable
A Laravel package to make your Eloquent models translatable.
Requires
- php: >=8.1
README
A small Laravel package to store and query translated model attributes using a polymorphic translations table.
Features
- Morph-many translation storage for any Eloquent model.
- Automatic attribute translation fallback when the base model value is
null. - Model-level control of which attributes are translatable.
- Search, sorting, and filter scopes for translated values.
- Built-in uniqueness validation rule for translation fields.
- Automatic translation cleanup when the parent model is deleted.
Requirements
- PHP 8.1+
- Laravel application (service provider is auto-discovered)
Installation
composer require mosamy/translatable
Run migrations:
php artisan migrate
Database Structure
The package migration creates a translations table with:
idlocaleattributebodytranslatable_typetranslatable_id
Unique index name: translation_unique
Unique columns:
translatable_typetranslatable_idlocaleattribute
Model Setup
1) Add the trait
use Illuminate\Database\Eloquent\Model;
use Mosamy\Translatable\Translatable;
class Post extends Model
{
use Translatable;
}
2) Configure translatable attributes (recommended)
Use the #[Translatables] class attribute:
use Mosamy\Translatable\Attributes\Translatables;
#[Translatables(attributes: ['title', 'description'])]
class Post extends Model
{
use Translatable;
}
restrict controls fallback behavior for unknown attributes:
#[Translatables(attributes: ['title', 'description'], restrict: false)]
restrict: true(default): only listed attributes can fallback to translated values.restrict: false: any missing attribute can fallback totranslate($key).
Creating / Replacing Translations
Use createTranslations(array $translations) with this payload shape:
$post = Post::create(['status' => 'active']);
$post->createTranslations([
'en' => [
'title' => 'Post Title',
'description' => 'Post Description',
],
'fr' => [
'title' => 'Titre de l\'article',
'description' => 'Description du post',
],
]);
Behavior notes:
- Existing translations for the model are deleted first, then new rows are inserted.
- Falsy values are skipped (for example:
null,'',0,false).
Reading Translations
The trait automatically appends translations to model $with, so translations are eager-loaded by default.
$post = Post::find(1);
$post->translations; // raw translation models
$post->translations_list; // grouped as locale => [attribute => body]
Example translations_list:
{
"en": {
"title": "Post Title",
"description": "Post Description"
},
"fr": {
"title": "Titre de l'article",
"description": "Description du post"
}
}
Attribute Fallback
getAttribute() fallback behavior:
- If base model value is not
null, the base value is returned. - If base model value is
null, the trait may return a translated value depending on your#[Translatables(...)]config.
$post = Post::find(1);
echo $post->title; // may fallback to translated value
echo $post->translate('title'); // current app locale
echo $post->translate('title', 'fr');
If no translation exists for locale/attribute, translate() returns null.
Query Scopes
whereTranslation($keyword, $attributes = [], $locale = [], $like = true)
Post::whereTranslation('keyword')->get();
Post::whereTranslation('keyword', ['description'])->get();
Post::whereTranslation('keyword', ['description'], ['ar', 'en'])->get();
Post::whereTranslation('keyword', ['description'], ['ar', 'en'], false)->get();
Notes:
- If
$attributesis empty andTranslatableAttributesconstant exists, that constant is used. - If
$localeis empty, current app locale is used. $like = trueusesLIKE %keyword%.$like = falseuses exact match (=).
hasTranslation($locale = null) and hasCurrentTranslation()
Post::hasTranslation()->get();
Post::hasTranslation('en')->get();
Post::hasCurrentTranslation()->get();
orderByTranslation($attribute, $sort = 'asc', $locale = null)
Post::orderByTranslation('title')->get();
Post::orderByTranslation('title', 'desc', 'fr')->get();
translateOnly($attributes)
Limit eager-loaded translations to specific attribute names:
Post::translateOnly('title')->get();
Post::translateOnly(['title', 'description'])->get();
Validation Rule
Use Mosamy\Translatable\Rules\Unique:
use Mosamy\Translatable\Rules\Unique as TranslationUnique;
public function rules(): array
{
return [
'translations.ar.title' => [
'required',
(new TranslationUnique(new Post()))->ignore($this->id),
],
'translations.en.title' => [
'required',
(new TranslationUnique(new Post()))
->setLocale(['ar', 'en'])
->ignore($this->id),
],
];
}
Rule behavior:
- If
setLocale()is not called, locale is inferred from field path. Example:translations.ar.title-> localear. - If rule attribute is not passed in constructor, attribute is inferred from field path.
ignore($id)excludes a model ID during update checks.
Delete Behavior
Translations are automatically deleted when the parent model is deleted.
- Normal delete: translations are deleted.
- Soft delete: translations are also deleted (the cleanup runs on the model
deletingevent).
Manual deletion is also possible:
$post = Post::find(1);
$post->translations()->delete();
$post->translations()->where('locale', 'fr')->delete();
Breaking Changes / Upgrade Notes
- Preferred configuration moved to PHP class attribute
#[Translatables(...)]. - Legacy
TranslatableAttributesconstant remains relevant forwhereTranslation()default attributes. - Legacy
$translatableproperty is still supported when class attribute is not used. - Automatic translated fallback now depends on
restrictbehavior and only happens when base attribute value isnull.
Support
For support, contact dev.mohamed.samy@gmail.com.
Author
Created by Mohamed Samy