Model or dataset
spatie/laravel-translatable avatar
spatie/laravel-translatable

spatie/laravel-translatable: JSON Columns Instead of a Translations Table

Making Eloquent models translatable

2,461 stars297 forksPHPMIT

At a glance

What is it?
The HasTranslations trait stores each translated attribute as a JSON column on the model itself. It suits small, fixed locale sets and simple Eloquent reads, and it stops being the right tool once you need per-locale indexing, joins or a translation workflow.
Who is it for?
Adopt spatie/laravel-translatable when your translatable content is a handful of short attributes on a model, your locale list is known ahead of time, and you want translation reads to stay ordinary Eloquent attribute access with no join.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 96 days ago.
What is it written in?
Mainly PHP, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem: translated attributes without a second table

Most Laravel i18n approaches add a translations table joined back to the parent model. That means every read of a translated field becomes a join or an eager load, and every write becomes a row insert. spatie/laravel-translatable takes the opposite route: the translation lives in the same row, in a JSON column, and the trait intercepts attribute access so `$newsItem->name` returns the value for the current app locale. The README states the design intent plainly: translations are stored as json, and there is no extra table needed to hold them.

The audience is a Laravel developer with a small, known set of locales and a model whose translatable content is a few short text fields. A news item with a name and a description, a product with a title, a category with a slug per language. If that describes your schema, the trait removes an entire table, its foreign key, and the join from every query that touches the model.

How HasTranslations intercepts attribute access

The trait is applied to an Eloquent model, and the translatable column names are declared either through a PHP attribute or a public property. The README shows both forms and states that when both are present their values are merged and deduplicated. The attribute form uses `#[Translatable('name', 'description')]` from `Spatie\Translatable\Attributes\Translatable`, and the property form uses a public `$translatable` array.

Once declared, the trait takes over get and set for those column names. `setTranslation('name', 'en', 'Name in English')` writes into the JSON payload under the `en` key. Reading `$newsItem->name` resolves the current app locale and returns the matching entry, so `app()->setLocale('nl')` changes what the same property access returns. `getTranslation('name', 'nl')` bypasses the locale resolution and asks for one language explicitly, and `getTranslations('name')` returns the whole array.

The nested-key support is the part worth noticing. If the translatable declaration is `'meta->description'`, the trait writes into a path inside the JSON column rather than replacing the column. The README shows both the setter and the getter for that path, including the `$newsItem->$attributeKey` variable-property form. That is a real mechanism, not a convenience wrapper: it means one JSON column can hold a mix of translated and untranslated structure, which is useful when a settings blob only needs one or two fields localized.

Querying translated columns with whereLocale and whereJsonContainsLocale

Storing translations in JSON makes the write path simple and the query path harder, and the package addresses that with four scopes. `NewsItem::whereLocale('name', 'en')->get()` returns records that have a name in English. `whereLocales('name', ['en', 'nl'])` widens that to either language. The other pair filters on value rather than presence: `whereJsonContainsLocale('name', 'en', 'Name in English')` and `whereJsonContainsLocales('name', ['en', 'nl'], 'Name in English')`.

The README documents a fourth argument on the value-comparing scopes, called the operand, which changes the comparison operator. Passing `'like'` with the value `'Name in%'` turns the exact match into a pattern match. That is the escape hatch for search boxes that filter translated titles by prefix.

All four depend on the database's JSON containment support underneath. The documentation for the scopes does not enumerate which database engines are covered, so if you are running anything other than MySQL or PostgreSQL, check the test suite and the driver behaviour before you build a listing page on top of these scopes.

Installing it and writing a first translated model

The package installs through Composer as `spatie/laravel-translatable`. The README does not include a service provider registration step, which is consistent with Laravel package auto-discovery, and it does not document a config file to publish. The repository does contain a `resources/` directory, so there is something beyond PHP in the package, but the README does not describe what it holds.

The README's own testing command is the only shell command it gives:

bash
composer test

That runs the package's test suite from a checkout, not something you run in your application. For a first real use, start from a model with a JSON column. The README does not print a migration, but it is explicit that translations are stored as json, so the column type has to match, and the README's examples all run against a `NewsItem` model with `name` and `description`. Declare the model with the attribute form the README shows:

php
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\Attributes\Translatable;
use Spatie\Translatable\HasTranslations;

#[Translatable('name', 'description')]
class NewsItem extends Model
{
    use HasTranslations;
}

Writing and reading follows the README's example. The setter chain ends in `save()`, and the plain property read resolves against the current app locale:

php
$newsItem = new NewsItem;
$newsItem
    ->setTranslation('name', 'en', 'Name in English')
    ->setTranslation('name', 'nl', 'Naam in het Nederlands')
    ->save();

$newsItem->name; // Returns 'Name in English' given that the current app locale is 'en'
$newsItem->getTranslation('name', 'nl'); // returns 'Naam in het Nederlands'

After the save, a fresh `NewsItem::first()` should return the same values through the same property access. If `$newsItem->name` comes back as a raw JSON string instead of the translated value, the column is not declared in `$translatable` or in the `#[Translatable]` attribute, and the trait never took over that key.

Where the JSON-column design costs you

The trade-off is the one the README does not spend words on. Because each translated attribute is one JSON column, you cannot index a single locale's value the way you would index a column in a translations table. Sorting a listing by the English name means sorting on a JSON extraction, and the package's documented scopes cover presence and containment, not ordering. If your product page needs `orderBy` on a translated title, you are writing raw JSON expressions yourself.

Aggregation has the same shape. Counting how many records have a Dutch description is a containment query; summing or grouping by a translated value is not something the documented API addresses.

The other cost is editorial. Translations live inside the parent row, so there is no translation record to assign, review, or mark stale. A workflow where a translator claims a row, submits it, and an editor approves it has nothing to attach to. The trait gives you read and write access to a JSON payload and stops there.

A third constraint is the locale set itself. Every locale adds a key to every translatable column on every row. For a handful of languages that is fine. For twenty, the column grows with the row count and every read pulls the whole payload for that attribute unless the database can project into JSON.

Astrotomic/laravel-translatable takes the table route

The most direct alternative in the PHP ecosystem is Astrotomic/laravel-translatable, which appears in the related searches and is the package people most often weigh against this one. The difference is architectural rather than cosmetic. Astrotomic stores each translation as a row in a dedicated translations table with its own primary key, keyed by locale and pointing back to the parent model through a relation. Reading a translated attribute goes through that relation.

That buys you things the JSON approach cannot offer: each translation is a real row you can index, query, and reference; locale-specific columns can carry their own constraints; and a translation has an identity that a workflow can hang off. It costs you a join or an eager load on every read, a second table per translatable model, and more migration surface.

The README also lists a second alternative, DB-Fields-Translations, without describing it, so treat that entry as a pointer rather than a comparison. The choice between this package and Astrotomic comes down to one question: do you need translations to be entities, or do you need translated attributes to be convenient? The README's own framing answers it for the second case.

Maintenance, licence and what an upgrade costs

The repository is not archived, and the last push was on 2026-06-26. The most recent release listed is 6.14.1 on 2026-04-23, preceded by 6.14.0 the same day and 6.13.0 on 2026-02-21. That release cadence, with minor versions arriving every few months, is the pattern to plan against: this is a package that ships incremental changes rather than sitting still, so pinning a version in `composer.json` and reading CHANGELOG.md before a bump is the realistic upgrade posture. The README does not document a deprecation policy or a backwards-compatibility guarantee, so the changelog is the only upgrade signal the repository offers.

Licensing is MIT, per the README's badge and its licence section, which points at LICENSE.md. MIT is permissive and imposes no copyleft obligation on your application, but this is a description of the licence text, not legal advice; if your organisation has a policy on third-party dependencies, run it through that process. The README also asks for a postcard if the package reaches production, which is a request and not a licence term.

One maintenance detail worth flagging: the README directs security reports to [email protected] rather than the issue tracker. If you fork or vendor the package, that channel stops being available to you.

Editorial conclusion

Adopt spatie/laravel-translatable when your translatable content is a handful of short attributes on a model, your locale list is known ahead of time, and you want translation reads to stay ordinary Eloquent attribute access with no join. Do not adopt it when you need translations as first-class rows that editors query and update independently, when you need to sort or aggregate on a translated value, or when the locale set is large enough that JSON column width becomes a problem. Before committing, verify two things on your own schema: that the columns you want to translate are declared as json (or text) in the migration, and that your database's JSON containment support covers the whereJsonContainsLocale queries you intend to write.

Frequently asked questions

How do I install spatie/laravel-translatable?

It installs as the Composer package spatie/laravel-translatable. The README does not list a service provider registration step or a config file to publish, so the install is the Composer require plus declaring the trait on your model.

Does spatie/laravel-translatable need an extra translations table?

No. The README states that translations are stored as json and that there is no extra table needed to hold them, so each translated attribute lives in a JSON column on the model's own row.

How do I query records that have a translation in a specific locale?

The package provides whereLocale and whereLocales for presence, and whereJsonContainsLocale and whereJsonContainsLocales for matching a value. The value-comparing scopes accept a final operand argument, so passing 'like' with 'Name in%' performs a pattern match.

How do I perform translation in Laravel?

With this package, you declare the translatable column names through the #[Translatable] attribute or a public $translatable property, then write values with setTranslation and read them back through the attribute or getTranslation. The README notes that when both declarations are present, their values are merged and deduplicated.

Does Laravel have an orm?

Yes, Eloquent, and this package is built on top of it: HasTranslations is a trait you apply to an Eloquent model, and the documented query scopes are called as static methods on that model.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. spatie/laravel-translatable on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/spatie-laravel-translatable.svg)](https://hysenlabs.com/projects/spatie-laravel-translatable)