mosamy/translatable

A Laravel package to make your Eloquent models translatable.

Maintainers

Package info

bitbucket.org/mohamedsamy_10/translatable

pkg:composer/mosamy/translatable

Statistics

Installs: 97

Dependents: 0

Suggesters: 0

1.3.5 2026-04-04 14:57 UTC

This package is auto-updated.

Last update: 2026-04-04 14:58:09 UTC


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:

  • locale
  • attribute
  • body
  • translatable_type
  • translatable_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:

  • createTranslations deletes 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 $attributes is empty, it uses TranslatableAttributes (if defined).
  • If $locale is empty, it uses current app locale.
  • $like = true performs a LIKE %keyword% search.
  • $like = false performs 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 => locale ar.
  • 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

  • createTranslations is 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 translations to $with, every query eager-loads translations unless explicitly changed.

License

MIT