· 7 min read

spatie/laravel-translatable vs Lang Files vs a TMS: What to Use for Which Content

"Should I use spatie/laravel-translatable or lang files?" is one of the most common Laravel i18n questions, and it's slightly the wrong question. The two solve different problems, and if you're asking, the useful answer is usually "both, for different content."

Getting the split right early matters because it's costly to change later. Moving content from database columns into lang files means a migration and a rewrite of every read site; moving the other way means the same in reverse.

The actual dividing line

Ask one question about each piece of text: does it change when someone edits a record, or when someone edits the app?

  • Interface text — buttons, labels, validation messages, emails, error states. There's a fixed set of it, it changes when developers change the product, and every language has a translation of the same string. This is lang files.
  • Content — product names, blog posts, category descriptions, CMS pages. It's created by users or editors at runtime, the set grows without a deploy, and it belongs to a specific database row. This is spatie/laravel-translatable.

The mistake in each direction looks like this:

// Wrong: product names in a lang file.
// Adding a product now requires a deploy, and the file grows forever.
// lang/en/products.php
return [
    'sku_11482' => 'Wireless Keyboard',
    'sku_11483' => 'USB-C Hub',
];
// Wrong: interface text in the database.
// Now "Save" needs a seeder, a migration for every new label,
// and no translator tooling can see it.
Setting::create(['key' => 'button_save', 'value' => ['en' => 'Save', 'es' => 'Guardar']]);

Both are recoverable, and both waste a month.

Lang files: what they're good at

Laravel's own mechanism, no packages needed. Keys resolve through __(), and everything in the ecosystem — IDE tooling, static analysis, translation platforms, Laravel-Lang/lang — understands them.

// lang/es/cart.php
return [
    'checkout' => 'Finalizar compra',
    'items' => ':count artículo|:count artículos',
];
<button>{{ __('cart.checkout') }}</button>
<span>{{ trans_choice('cart.items', $count) }}</span>

Strong points: no database queries, cached at the framework level, plural forms handled by trans_choice(), and version-controlled so translation changes show up in review.

The real limitation isn't technical, it's operational: changing a file means a deploy. A marketing lead who wants to reword a CTA has to open a ticket. That constraint is what pushes teams toward a TMS, and it's worth naming honestly rather than pretending files are free.

spatie/laravel-translatable: what it's good at

It stores translations as JSON in a single column and gives you locale-aware accessors:

use Spatie\Translatable\HasTranslations;

class Product extends Model
{
    use HasTranslations;

    public array $translatable = ['name', 'description'];
}
$product = Product::create([
    'name' => ['en' => 'Wireless Keyboard', 'es' => 'Teclado inalámbrico'],
]);

$product->name;                         // follows the app locale
$product->getTranslation('name', 'es'); // "Teclado inalámbrico"
$product->setTranslation('name', 'de', 'Kabellose Tastatur');

No extra tables, no joins, no hasMany translation relation to eager-load. For content that's read as part of a record you already fetched, that's exactly right.

Two things to plan for. Querying inside JSON works but doesn't use ordinary column indexes:

Product::where('name->es', 'like', '%teclado%')->get();

On MySQL and PostgreSQL that's a JSON path expression. It's fine at moderate scale and it's the wrong tool for your primary search — if products need real search, index them somewhere built for it rather than making JSON LIKE queries carry that load.

Fallbacks need configuring, or a record with no translation in the current locale returns an empty string rather than the source language. spatie/laravel-translatable supports fallback locales; set them deliberately, and decide what "missing" should look like in your UI.

Where a TMS fits

A translation management system isn't a third storage mechanism — it's a workflow layer over your interface text. It exists to answer questions files alone don't:

  • Which keys are missing in Portuguese, right now?
  • Who changed this string, and when?
  • Can a non-developer fix a typo without a deploy?
  • Can we machine-translate 400 new keys and have a human review them?

If you have one language, files are enough. If you have two and both are maintained by developers, files are still probably enough. The pain starts around the point where the number of languages times the rate of copy change exceeds what anyone wants to handle in pull requests — in practice, three or more languages with active copy, or the first time a non-developer needs to change text.

This is where LangSyncer sits, and it's worth being precise about the boundary: it manages the interface-text layer — your lang files — and doesn't replace spatie/laravel-translatable for database content. Import your existing files, translate the gaps with AI, review, and publish to a CDN in seconds without a deploy. Your Product model keeps doing what it does.

The combination in practice

A typical e-commerce app ends up with all three:

// Interface text → lang files (managed through a TMS)
__('cart.checkout')
trans_choice('cart.items', $count)

// Content → spatie/laravel-translatable
$product->name
$category->description

// Dates and numbers → Carbon and Intl, not translation at all
$order->created_at->locale($locale)->isoFormat('LL')
Number::currency($order->total, in: $currency, locale: $locale)

That third line is worth calling out because it's routinely mishandled: dates, currencies and number formats are locale conventions, not translations. They don't belong in either storage mechanism. We went through the date side of this in localized dates with Carbon.

Decision table

| Content | Mechanism | Why | |---|---|---| | Buttons, labels, nav | Lang files | Fixed set, changes with the app | | Validation messages | Lang files | Laravel resolves them there natively | | Transactional emails | Lang files | Same lifecycle as the code that sends them | | Product / category names | spatie/laravel-translatable | Per-record, created at runtime | | CMS pages, blog posts | spatie/laravel-translatable | Per-record, editorial workflow | | Enum labels (order status) | Lang files | Fixed set defined in code | | User-generated content | Usually neither | Store as authored; translate on demand if at all | | Dates, currency, numbers | Carbon / Number / Intl | Locale conventions, not translations |

The enum row catches people out. A status enum has a fixed set of cases defined in code, so its labels are interface text even though the value lives in a database column:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Shipped = 'shipped';

    public function label(): string
    {
        return __("orders.status.{$this->value}");
    }
}

Migrating between them

If you already picked wrong, the direction matters.

Content out of lang files into the database is the easier fix: a migration adding the JSON column, a one-off command reading the file and writing rows, then delete the file. Since keys were probably ID-shaped (sku_11482), the mapping is mechanical.

Interface text out of the database into lang files is harder, because call sites are usually inconsistent — some read the setting directly, some cache it, some have a fallback string inline. Budget for finding every read site. Our Blade scanner helps with the related job of finding text that was never in either system, which in an app that got this wrong is usually a substantial amount.

Summary

  • Split on lifecycle: changes with the app → lang files. Changes with a recordspatie/laravel-translatable.
  • Lang files are fast and reviewable; their cost is that changes need a deploy.
  • spatie/laravel-translatable avoids joins; plan JSON query performance and fallback locales deliberately.
  • A TMS is a workflow layer over interface text, not a third storage option, and earns its place around three actively-maintained languages or the first non-developer editor.
  • Enum labels are interface text. Dates and currencies are neither — they're locale conventions.

If you're at the point where interface text is the bottleneck, LangSyncer imports your existing lang files and gives you coverage per language, AI translation for the gaps, and publishing without a deploy. Per-language specifics — plural form counts, date conventions, RTL notes — are in our Laravel localization guides.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content