spatie/laravel-translatable
A Laravel trait that makes Eloquent model attributes translatable, storing translations as JSON with no extra table needed. Set and retrieve per-locale values, with fallbacks for missing translations.
Architecture fit. This is a thin, opinionated persistence pattern rather than a platform: translations live as JSON documents in a column on the model's own table, resolved through an Eloquent trait. That fits a Laravel application with a modest, known locale set (roughly 2–15 locales) and content-shaped models — articles, products, categories, CMS pages — where reads dominate and per-locale relational querying is not a core product requirement. It is a poor fit where translation rows need their own lifecycle (per-locale approval workflows, per-locale authorship/audit, translation-memory integration), or where a translation must be referenced by foreign key or constrained at the column level.
The pattern's real architectural value is its cost profile: adding a locale is a data operation, not a schema migration, and no join is required to render a page. The corresponding cost is that the relational model is flattened — anything that wants "one row per locale" (reporting, bulk translation tooling, external TMS sync) has to explode the JSON at read time.
Integration feasibility. High. It is a Composer dependency plus a trait and a $translatable declaration; there is no service provider work, no migration shipped by the package, and no queue or cache infrastructure implied. Two integration details are notable: the #[Translatable('name', ...)] PHP attribute (added in 6.14.0) is the preferred declaration and is friendlier to static analysis and code review than the legacy public property, and the two are merged and deduplicated when both exist. Also relevant: whereLocale, whereLocales, whereJsonContainsLocale(s) give DB-side filtering without hand-written JSON SQL, including an operator argument for like — that materially reduces the custom query surface a team would otherwise own.
Technical risk. The dominant risk is not code quality, it is implicit behavior change on existing columns. Once the trait is on a model, $model->name returns a locale-resolved string whose value depends on ambient app()->setLocale() state, and a missing locale silently yields a fallback or empty value rather than an error. Retrofitting this onto an existing single-language column converts a deterministic read into a locale-dependent one, and that change is invisible in diffs. Secondary risks: bulk and raw writes (Model::where(...)->update([...]), insert, DB::table, some seeders) bypass the mutator and write corrupt or non-localized JSON; any cache layer keyed on model ID without the active locale will serve the wrong language; and null/empty-string semantics have shifted repeatedly across 6.9–6.11 (including one revert in 6.9.3), so behavior is version-sensitive. Query performance on JSON path predicates depends on the database — MySQL 8 functional indexes, Postgres jsonb with GIN, SQLite JSON1 for tests — and needs an EXPLAIN check on representative volumes, not an assumption.
Key questions.
Dependents: 0 for a 2.4k-star Spatie package? That is almost certainly a data-collection gap; verify against Packagist download counts before treating adoption risk as unproven.Stack fit. Native Laravel: the package tracks framework releases closely (6.13.0 added Laravel 13 support) and cleaned up PHP 8.4 nullable-parameter deprecations in 6.9.0, so it is a low-friction addition to a current Laravel/PHP 8.2+ codebase. MIT license removes procurement friction. The absence of a service provider or published config means it does not participate in application bootstrapping — a plus for blast radius, a mild minus for centralized configuration of fallback/null semantics.
Migration path. Sequence additive-only to start: introduce new JSON columns (name, description) alongside the existing source of truth, enable the trait, and dual-write through model code paths so the mutator is always exercised. Backfill historical data with a one-off, idempotent job that maps legacy locale columns or translations-table rows into {"en": "...", "nl": "..."} maps, then run a reconciliation diff — row counts per model, and per-locale non-empty coverage — before flipping reads. Cut reads behind a feature flag scoped to internal users first, then all traffic. Drop the legacy columns only after one full release cycle of clean dual-write, and keep the migration reversible until then. If the source is a normalized translations table, expect the interesting work to be collision handling (duplicate locale rows) and normalizing locale codes (en vs en_US vs en-GB) — pick one canonical form before backfill, not after.
Compatibility. Confirm JSON column type choice per engine: Postgres jsonb (indexable, and off-page TOAST storage helps with long text), MySQL 8 json, SQLite for test parity. The JSON scope methods must be validated on the actual driver, since SQLite-in-CI passing does not prove Postgres GIN behavior. Ensure the ORM layer — not query builder — owns all writes to translatable columns, and that API contracts expose translations as an explicit locale map on write rather than relying on ambient locale, otherwise a background job's locale leaks into persisted data.
Sequencing. (1) Timeboxed spike on two representative models with a production-shaped dataset and an EXPLAIN review. (2) Decision record on storage pattern versus a normalized alternative, with the locale roadmap attached. (3) Schema change + backfill + reconciliation, no read changes. (4) Dual-write, flag-gated read cutover, observability on missing-translation rates. (5) Adopt the JSON scopes and retire hand-written locale filters. (6) Drop legacy columns. Roughly two to four engineering weeks for a mid-sized app, with the backfill and editorial tooling dominating — the package itself is hours.
Maintenance. Actively maintained, long-lived, well-known vendor with a documented changelog, tagged releases, and a test suite the package runs publicly. The maintenance burden shifts to us in one specific way: minor releases have shipped semantic changes to null/empty handling (6.9.0, 6.10.1, 6.11.3) and at least one revert (6.9.3), so pin exact versions, read every changelog entry, and treat upgrades as a small change with a regression pass over translation edge cases rather than a routine bump. The Postcardware request is a courtesy, not an obligation.
Support. MIT with no SLA; GitHub issues and the Spatie docs site are the channels. That is adequate for a package of this size, but it means no vendor escalation path if a locale-resolution bug lands during a launch. Budget for internal ownership — one engineer who understands the trait's fallback semantics is worth more than any support contract here.
Scaling. Read path scales normally: no joins, one row per record, indexable JSON paths. Adding locales is cheap operationally. The pressure points are (a) row width and write amplification once text-heavy columns carry many locales — size it, and prefer Postgres jsonb if text is long; (b) cross-locale analytics, which will need extraction or a derived read model; (c) full-text search, which effectively requires per-locale searchable projections rather than querying the JSON column directly; (d) "find all records missing locale X" queries, which are expressible but easy to write badly.
Failure modes to instrument. Silent fallback — a missing de translation renders English to a German user with no error and no alert; track missing-translation rate per locale and per model. Corrupt JSON from bypassed mutators — add a code-review guard or static check that flags direct assignment to translatable columns in raw queries and bulk updates. Cache poisoning — every cache key for a translatable model must include the locale; make this a shared helper rather than a convention. Locale-code drift between writes and reads, which produces the fallback behavior above with a confusing root cause. Malformed pre-existing JSON encountered during backfill, which should fail loudly and be quarantined, not coerced to empty.
Ramp-up. Genuinely low for a Laravel developer — hours to first productive use, given the README, docs site, and worked examples in the repo. The non-obvious parts are exactly the failure modes above, so invest in a short internal playbook (locale resolution rules, cache key rule, raw-write prohibition, missing-translation metrics) and a handful of guardrail tests. That converts a package that is easy to adopt into one that is hard to misuse.
How can I help you explore Laravel packages today?