barisdemirhan/laravel-polyglot-model
A Laravel package that adds multilingual support to Eloquent models, letting you store translations for model attributes and retrieve them automatically based on the current application locale.
The package targets Laravel 10 through 13 on PHP ^8.1. Install it and pin the major:
composer require barisdemirhan/laravel-polyglot-model:^1.1
Nothing else is required for the upgrade path — v1.1.0 ships no migrations and no config changes, so composer update is the whole migration. If the package publishes a config file at all, do php artisan vendor:publish --tag=polyglot-model-config once and commit it; otherwise treat the package as zero-config.
Where to look first, in order:
src/ directory for the translation attribute name convention (which JSON column it expects, and how it detects the current locale).First use case: take an existing model with a single-language title / body, add a JSON column, attach the package's translation trait, and move those attributes into the multilingual store. Concretely:
// migration
Schema::table('posts', function (Blueprint $table) {
$table->json('title')->nullable();
$table->json('body')->nullable();
});
// model (verify the exact trait name in the README)
class Post extends Model
{
use \Barisdemirhan\PolyglotModel\Concerns\HasTranslations;
public array $translatable = ['title', 'body'];
}
$post->title = ['en' => 'Hello', 'tr' => 'Merhaba']; // or set per-locale, per README
app()->setLocale('tr');
$post->title; // 'Merhaba'
That single flow — assign a map, read through the active locale — is 90% of what you will actually do with this package.
Store the translation map, render the resolved string. Keep the habit of writing maps (in seeders, imports, admin forms) and reading scalars (in views, mail, API resources). Mixing the two in one layer is where translation bugs come from.
Form handling. For multi-locale edit screens, bind the raw map to the form, not the resolved value:
$post->getTranslations('title'); // or the package's equivalent accessor
Build the request validation around per-locale wildcards so a missing Turkish field fails at validation instead of at render:
'title' => ['required', 'array'],
'title.*' => ['required', 'string', 'max:255'],
API resources. Decide per endpoint whether you emit the resolved string (thin client, one locale per request) or the full map (client-side language switch). Do not rely on default model serialization — it will leak the raw JSON for every locale, which is both a payload and a content-exposure problem:
return [
'title' => $post->title, // resolved for the request locale
// or
'title' => $post->getTranslations('title'),
];
Locale must come from the request, not the worker. Set the locale in middleware for HTTP and re-set it at the top of each queued job and each scheduled command. Long-running Octane/Swoole workers and queue workers inherit locale from whatever ran previously.
Querying. Filtering on translated columns means querying inside JSON. For simple "does a translation exist" checks this is fine; for anything in a hot path, prefer an explicit indexed column (title_en) written alongside the JSON, or a generated column, and let the package handle the read path only.
Admin panels (Filament/Nova) and imports. Wrap them behind a small service or form object that normalizes input into a full locale map before hitting the model, so incomplete maps do not reach the database. Same for CSV/API imports — normalize to the default locale key, never store a bare string.
Testing. Assert through app()->setLocale() in the test, and cover the fallback path explicitly: a model with only en populated read while locale is tr. Use RefreshDatabase as usual; the JSON column round-trips fine on SQLite for tests, but see gotchas below.
Laravel 13 upgrade. v1.1.0 is additive-only, so the day-to-day workflow does not change. Your upgrade checklist is: bump the constraint, run the suite, then confirm the rest of your dependency tree supports Laravel 13 — this package's support is not the blocker, your other packages are. The published matrix (PHP 8.1/L10 through PHP 8.5/L13) means you can sit on any of those combinations and stay supported, so there is no forced platform upgrade.
JSON columns are not searchable by default. LIKE against a JSON column matches the serialized blob, including keys — a search for "en" matches every row. If you need full-text search over translations, use a dedicated search index (Scout) or duplicated columns.
Do not index the whole JSON column and expect lookups to use it. MySQL 8 functional indexes and Postgres jsonb GIN indexes are the escape hatch; SQLite in tests will simply behave differently, so a green test suite does not prove your production queries are efficient.
Empty string vs. null vs. missing key. These are three different states and the package's fallback logic may only handle some of them. Pick a convention (empty string means "intentionally blank, do not fall back") and enforce it in your form objects, or you will get English leaking into intentionally blank Turkish fields.
Fallback order is configuration, not magic. Confirm how the fallback locale is resolved — app fallback locale vs. per-model override — and test the case where the fallback is also missing. A null returned from a translatable attribute will blow up {{ }} in Blade less often than you expect but will break strlen(), sorting, and Str::limit() silently.
Locale switching in tests leaks. app()->setLocale() persists for the remainder of the test class unless reset; put it in tearDown() or use a helper that restores the previous locale.
Raw maps in toArray()/toJson(). If default serialization exposes the full map, either add the translatable attributes to $hidden and expose them via resources, or override serialization. Check this before shipping — it is the most common accidental data leak with translation packages.
Mass assignment and casts. The translation column needs to be in $fillable/$guarded handling like any other attribute, but assigning through the trait may bypass the normal cast pipeline. If you also declare the column in $casts as array, verify you are not double-encoding — the trait usually wants to own the cast.
Queue and Octane hygiene. As above: reset locale per job. Also be careful with Mail/Notification — they capture the locale at send time, so pass the recipient's locale explicitly rather than relying on the ambient one.
Extension points. If the package exposes a translation resolver or a locale-provider binding, that is the right place to plug in a tenant-aware or per-user language preference instead of scattering App::setLocale() calls. Check the service provider for what is bound as a singleton before you write your own wrapper.
Debugging tip. When a translation "disappears", dump the raw attribute ($model->getRawOriginal('title')) rather than the accessor — it separates "not saved" from "saved but not resolved for this locale", which are very different bugs.
Version pinning. ^1.1 is safe given the stated no-breaking-change guarantee, and it keeps you eligible for the Laravel 13 line as you upgrade. The 11 CI checks (test matrix plus code style) are a reasonable signal, but they do not cover your database — validate the JSON round-trip on your actual engine (MySQL vs. MariaDB vs. Postgres) before relying on it in production.
How can I help you explore Laravel packages today?