mosamy / translatable
A Laravel package to make your Eloquent models translatable.
Requires
- php: >=8.1
README
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 when reading declared translatable fields.
- Search, sort, and filtering scopes for translated data.
- Built-in uniqueness validation rule for translation values.
- Automatic cleanup of translations when the parent model is deleted.
Requirements
- PHP 8.1+
- Laravel application (the package auto-discovers its service provider)
Installation
composer require mosamy/translatable
Run migrations:
php artisan migrate
The package migration creates a translations table with these columns:
localeattributebodytranslatable_typetranslatable_id
It also adds a unique index on:
translatable_type, translatable_id, locale, attribute.
Model Setup
Add the trait and define TranslatableAttributes on your model.
use Illuminate\Database\Eloquent\Model;
use Mosamy\Translatable\Translatable;
class Post extends Model
{
use Translatable;
public const TranslatableAttributes = ['title', 'description'];
}
Create or Replace Translations
Use createTranslations(array $translations) with this exact 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',
],
]);
Important behavior:
createTranslationsdeletes current translations for the model, then inserts new ones.- Empty/falsy values are skipped and not stored.
Read Translations
The trait automatically eager-loads translations for models using it.
$post = Post::find(1);
$post->translations; // raw translation rows
$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"
}
}
Direct Attribute Translation
If an attribute is listed in TranslatableAttributes, reading it calls translate() automatically:
$post = Post::find(1);
echo $post->title; // same as $post->translate('title')
echo $post->translate('title');
echo $post->translate('title', 'fr');
If no translation exists for the selected locale/attribute, the returned value is 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, it usesTranslatableAttributes(if defined). - If
$localeis empty, it uses current app locale. $like = trueperforms aLIKE %keyword%search.$like = falseperforms 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)
Limits eager-loaded translations relation to selected attributes.
Post::translateOnly('title')->get();
Post::translateOnly(['title', 'description'])->get();
Validation Rule
The package provides 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 you do not call
setLocale(), locale is inferred from the field path. Example:translations.ar.title=> localear. - If you do not pass an attribute in the rule constructor, attribute is inferred from the field path.
ignore($id)excludes a model ID during update checks.
Deleting Translations
Translations are deleted automatically when the parent model is deleted.
- Normal delete: translations are deleted.
- Soft delete: translations are deleted only on force delete.
You can also delete manually:
$post = Post::find(1);
$post->translations()->delete();
$post->translations()->where('locale', 'fr')->delete();
Known Limitations / Suggested Improvements
createTranslationsis replace-all (not merge/update-in-place). This is intentional in current behavior, but should be considered when updating translations.- Because the trait auto-adds
translationsto$with, every query eager-loads translations unless explicitly changed.
License
MIT