Search by

vortech / laravel-localize

papp-mate

Typed, per-model translation tables for Eloquent: a locale view with exact fallback rules, transactional bulk saves and a unique validation rule.

Package info

github.com/Vortech-Group/localize

pkg:composer/vortech/laravel-localize

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-10-19 08:22 UTC

This package is auto-updated.

Last update: 2026-10-19 08:25:35 UTC


README

Laravel Localize by Vortech

Tests Latest version Downloads PHP version Laravel 13+ License

Typed translation tables for Eloquent models: one table per model with real columns.
An explicit locale view with exact fallback rules, partial transactional saves and a per-locale unique rule.

Why

Translations in a JSON column are hard to index, hard to constrain and hard to query. Localize gives every model its own translation table with ordinary columns, so a slug gets a unique index per locale and a description can be text.

JSON column Localize
Unique slug per locale no index to lean on a normal unique index, plus the UniqueTranslation rule
Column types everything is JSON string, text, whatever your migration says
Reading $model->name and a guess about the locale $model->locale('en')->name, always explicit
Fallback whatever the package decided requested locale, then the app locale, then null, per column
Saving rewrite the whole blob only the submitted columns, in one transaction
Translation classes one per model none

Requirements

  • PHP 8.4+
  • Laravel 13+

Installation

composer require vortech/laravel-localize
php artisan localize:install

localize:install publishes config/localize.php. The service provider is discovered automatically.

Usage

1. Declare the model

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Vortech\Localize\Attributes\Localized;
use Vortech\Localize\HasLocales;

#[Fillable('sku', 'price', 'stock')]
#[Localized('slug', 'name', 'description')]
class Product extends Model
{
    use HasLocales;
}

The listed columns are not part of the model's fillable columns: they live in the translation table and are checked against the attribute's whitelist.

2. Create the tables

php artisan localize:table Product
php artisan localize:table              # asks which model, from the ones in app/ that use HasLocales

generates the migration below from the model: the table name, the key type (foreignId, foreignUlid or foreignUuid), a nullable string per declared column (text for body, content, description, excerpt, notes, summary and text), the (product_id, locale) unique index and, with #[LocalizedSlug], the (locale, slug) one. Check the column types before you migrate. It accepts a class name or a short name from App\Models, and --force replaces a migration generated before. Written by hand it looks like this:

The schema is yours, in a migration you version. The translation table is the singular of the model's table plus _translations, with the model's foreign key (product_id).

Schema::create('product_translations', function (Blueprint $table): void {
    $table->id();

    $table->foreignId('product_id')->constrained('products')->cascadeOnDelete();

    $table->string('locale', 35);
    $table->string('slug')->nullable();
    $table->string('name')->nullable();
    $table->text('description')->nullable();
    $table->timestamps();

    $table->unique(['product_id', 'locale']);
    $table->unique(['locale', 'slug']);
});
  • unique(['product_id', 'locale']) is required: one row per model and locale.
  • Translatable columns should be nullable, so partial translations can be saved.
  • For UUID/ULID models use foreignUlid() / foreignUuid().
  • To name every table differently change table_suffix in the config. For one irregular model override getTranslationTable() and getTranslationForeignKey().

3. Read

$product->locale()->name;                         // in the app locale
$product->locale('en')->name;                     // in English, falling back to the app locale
$product->locale('en', fallback: false)->name;    // only what is stored for English, else null

Fallback works per column. With hu as the app locale:

// hu: name = 'Fekete póló', description = 'Pamut póló'
// en: name = 'Black T-shirt', description = null

$product->locale('en')->name;                          // Black T-shirt
$product->locale('en')->description;                   // Pamut póló
$product->locale('de')->name;                          // Fekete póló
$product->locale('en', fallback: false)->description;  // null
Stored value Result
row missing, or the column is null fallback to the app locale
'' ''
'0' '0'
missing in the app locale too null

app.fallback_locale is not a step, and there is no en-GB → en or "first existing language" fallback. Reading an undeclared column throws UnknownTranslationAttribute.

Reading as a property. $product->name is not translated unless the model opts in:

#[Localized('slug', 'name', 'description', magic: true)]
class Product extends Model { use HasLocales; }

$product->name;                  // the same as $product->locale()->name (the current app locale)
$product->locale('hu')->name;    // an explicit locale keeps working
$product->name = 'x';            // LogicException: write with locale()->update() or saveLocales()

Reading $product->name goes through the locales relation like any view, so eager load it in lists (lazy loading rules apply). Assigning is refused because the column does not exist on the model's own table. A real attribute of the same name with a value wins, so do not keep a translated column on the main table. The array form, $fillable and the dirty attributes never see the column.

A view fixes the requested locale and the app locale when it is created. A later app()->setLocale() does not change it.

4. Write

// one locale, saved immediately
$product->locale('en')->update([
    'name' => 'Black T-shirt',
    'slug' => 'black-t-shirt',
]);

// several locales, in one transaction
$product->saveLocales([
    'hu' => ['name' => 'Fekete póló'],
    'en' => ['name' => 'Black T-shirt', 'description' => null],
]);
Input Effect
omitted locale or column unchanged
null clears that column
'' stores an empty string
new locale creates the row
[] no-op
unknown column, wrong shape or value type exception, nothing is written

Both calls use the same writer. The payload is validated before the first query, everything runs in one transaction on the model's connection, and only the submitted columns are written. Locale keys are used exactly as given (en-GB and pt_BR both work). A fallback value is never copied into a locale.

The model must be saved first (UnsavedTranslationOwner otherwise), and the package never saves the model's own pending changes. To create a model and its translations together, wrap both in your own transaction:

$product = DB::transaction(fn (): Product => Product::create([...])->saveLocales([
    'hu' => ['name' => 'Fekete póló', 'slug' => 'fekete-polo'],
    'en' => ['name' => 'Black T-shirt', 'slug' => 'black-t-shirt'],
]));

Rows are inserted or updated, never upserted. On MySQL an upsert reacts to any unique index, so a slug clash could overwrite another product's row; here a clash raises a QueryException and the whole call rolls back. Two requests creating the same locale row for the first time at the same moment can also end in that exception: the unique index decides, and the loser should retry. There are no Eloquent events on the translation rows; see Events for the one this package dispatches.

5. Validate

Which languages and columns are required is your Form Request's decision. The package validates only the technical shape. For uniqueness per locale:

use Vortech\Localize\Rules\UniqueTranslation;

// a fixed locale
'slug' => ['bail', 'required', 'string', 'max:255',
    UniqueTranslation::for(Product::class, 'slug')->locale('en')],

// on update: the shorthand ignores the model itself
'slug' => ['bail', 'required', 'string', $product->locale('en')->unique('slug')],

// a bulk payload: the locale comes from the wildcard
'translations.*.slug' => ['bail', 'sometimes', 'nullable', 'string', 'max:255',
    UniqueTranslation::for(Product::class, 'slug')
        ->localeFrom('translations.*.slug')
        ->ignore($product)],
  • It checks stored rows of the target locale, with no fallback. The same value in another locale is fine.
  • It does not make a field required, and null, '' and missing values are skipped without a query.
  • localeFrom() takes a pattern with exactly one *. A pattern that does not match the attribute, or locale() combined with localeFrom(), is a configuration error, never a silent fallback to the app locale.
  • ignore() takes a saved model of the same type and excludes it by the translation table's foreign key. Pass a model from route binding, never an id from the request.
  • where(), whereNot(), whereNull() and whereNotNull() add conditions on translation table columns. They only help if your unique index has the same scope.
  • Case sensitivity follows the database collation.
  • The rule does not replace the unique index: a concurrent write can still win between validation and save.

6. Eager loading

locales() is a normal HasMany of the stored rows. The view reads the loaded relation, so after eager loading there are no queries per model:

$products = Product::query()->withLocales()->get();

foreach ($products as $product) {
    echo $product->locale('en')->name;
}

To load fewer rows, name the locales you read. The app locale you fall back to is always included:

Product::query()->withLocales(['en'])->get();

A callback as the second argument narrows the loaded rows further. It receives the translation table query, and a row it leaves out is also missing from the fallback:

Product::query()->withLocales(['en'], fn ($rows) => $rows->whereNotNull('slug'))->get();

If the relation is not loaded, the normal Laravel lazy loading rules apply, including Model::preventLazyLoading(). After a save, a loaded relation is reloaded; after refresh() the next read is current. A change made by another process or rolled back by an outer transaction is not seen until you reload.

7. Serialize and edit

$product->locale('en')->toArray();   // ['slug' => ..., 'name' => ..., 'description' => ...]

// edit forms: no fallback, so a fallback text is not mistaken for a finished translation
$form = [
    'hu' => $product->locale('hu', fallback: false)->toArray(),
    'en' => $product->locale('en', fallback: false)->toArray(),
];

The view is Arrayable, Jsonable (toJson()) and JsonSerializable, with the declared columns in declaration order. The model's own toArray() is untouched; in an API resource add 'translation' => $this->resource->locale()->toArray().

Events

After a write is committed, one Vortech\Localize\Events\TranslationChanged event is dispatched for every column that changed value. A column saved with the value it already has dispatches nothing, and neither does a write that fails.

Event::listen(function (TranslationChanged $event) {
    // $event->model     the owner (Product)
    // $event->key       'name'
    // $event->locale    'hu'
    // $event->oldValue  null on a new row
    // $event->newValue
});

A generated slug is a change like any other, so it dispatches too.

8. Query

Filtering by a translated column is explicit, in the locale you name:

Product::query()->whereLocale('name', 'en')->get();              // models with an English name
Product::query()->whereLocales('name', ['en', 'hu'])->get();     // with a name in English or Hungarian

Product::query()
    ->whereHasLocale('en', fn ($query) => $query->where('slug', 'black-t-shirt'))
    ->withLocales()
    ->firstOrFail();

Product::query()->whereHasLocale('de')->get();    // models that have a German row at all

whereLocale() and whereLocales() take a translatable column (anything else throws UnknownTranslationAttribute) and match rows where it is not null. whereHasLocale() takes a callback that receives the translation table query. None of them use the display fallback: it never changes what a query finds. withLocales() eager loads the stored rows; withLocales(['en']) loads only those locales plus the current app locale, which the fallback reads. All of them are Eloquent scopes, so they chain with anything and work on relations.

Route model binding by a translated slug is described under Slugs.

9. Deleting

Deleting a model removes its translations through the foreign key's cascadeOnDelete(). With soft deletes the translations stay and are used again after restore(). Setting a column to null does not delete the row.

10. Factories

withLocales() is added to every Eloquent factory. It saves the locales right after the model is created, for each model of a batch:

Product::factory()->withLocales([
    'hu' => ['name' => 'Fekete póló'],
    'en' => ['name' => 'Black T-shirt'],
])->create();

Product::factory()->withLocales(['hu', 'en'], ['name' => 'Póló'])->create();                  // same values for each locale
Product::factory()->withLocales(['hu', 'en'], [['name' => 'Póló'], ['name' => 'Tee']])->create(); // one entry per locale

With make() nothing is saved, because the model does not exist yet.

11. Locale routes

Route::localized() wraps routes in a {locale} URL prefix and the SetLocale middleware:

Route::localized(function (): void {
    Route::get('products/{product}', [ProductController::class, 'show'])->name('products.show');
});
// /hu/products/1, /en/products/1, /pt_BR/products/1 ...

Route::localized(fn () => ..., ['hu', 'en']);   // locales for this group only
  • The accepted locales are localize.locales (default ['en'], so set it), matched exactly. Anything else is a 404, the same way as a missing route.
  • SetLocale calls app()->setLocale(), so locale(), validation messages and dates follow the URL. It runs before route model binding, so a binding that reads the locale sees the right one.
  • It also sets URL::defaults(['locale' => ...]): route('products.show', $product) keeps the current locale without passing it, and route('products.show', [$product, 'locale' => 'en']) links to another one.
  • The locale parameter is removed from the route, so controllers and Request::route()->parameters() never see it.
  • Using SetLocale on a route without a {locale} parameter throws InvalidTranslationDefinition.

Redirecting / to a locale and detecting it from Accept-Language are yours to add.

12. Slugs

#[LocalizedSlug] generates the slug of each locale from another column, unique per locale:

#[Localized('slug', 'title', 'body')]
#[LocalizedSlug(from: 'title', to: 'slug')]   // these are the defaults 'name' and 'slug'
class Article extends Model
{
    use HasLocales;
}

$article->saveLocales(['hu' => ['title' => 'Fekete póló']]);   // slug: fekete-polo
  • A slug is generated when a save sets from, leaves to out, and the locale has no slug yet. Later title changes keep the slug, so URLs stay stable; regenerate: true makes it follow the title instead.
  • A slug you pass is stored as given, and an explicit null clears it. Your Request rules (UniqueTranslation) still decide whether a hand-written slug is valid.
  • Transliteration follows the locale (hu: póló → polo, de: Grüße → gruesse); pt_BR and en-GB use their language.
  • If another model already has the slug in the same locale, -2, -3, ... is added. The same title in another locale is not a clash.
  • Generation checks the slug table first and the unique index decides in the end: two requests saving the same new title at the same moment can still end in a QueryException.
  • Both columns must be declared with #[Localized].

13. Routing by slug

On a model with #[LocalizedSlug] the slug is the route key, so {article} binds by the slug in the URL's locale (no fallback) and the model comes back with its locales loaded:

Route::localized(function (): void {
    Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');
    Route::get('articles/{article:id}/edit', [ArticleController::class, 'edit'])->name('articles.edit');
});

route('articles.show', $article);   // /hu/articles/fekete-polo, the slug of the current locale

/en/articles/fekete-polo is a 404, as the slug belongs to Hungarian. Use {article:id} where you want the id (admin). Use {article}, not {article:slug}, on slug routes: only the plain form generates the URL from the model.

14. Language switcher, canonical and hreflang

LocaleLinks builds the current page's URL in every locale. It needs a named route inside Route::localized(); each bound slug model contributes its slug of the locale, and other parameters are reused.

$links = app(LocaleLinks::class);

$links->all();         // every locale: ->locale, ->url (null without a translation), ->current, ->available()
$links->alternates();  // only the available ones, without the query string
$links->canonical();   // this page in the current locale, without the query string

Two Blade components cover the common case:

<head>
    <x-localize::head />
</head>

<x-localize::switcher :labels="['hu' => 'Magyar', 'en' => 'English']" />

head renders <link rel="canonical"> and one <link rel="alternate" hreflang="..."> per available locale (pt_BR becomes pt-BR). switcher renders a list of links with aria-current on the current locale and a disabled entry where the page has no translation; the switcher keeps the query string. Publish the markup with php artisan vendor:publish --tag=localize-views.

There is no x-default alternate.

JSON columns

A column can hold structured data. Cast it with array and write and read it as an array:

#[Localized('title', 'features', casts: ['features' => 'array'])]
class Reference extends Model { use HasLocales; }

$reference->saveLocales(['hu' => ['features' => [['icon' => 'bolt', 'title' => 'Gyors']]]]);

$reference->locale('hu')->features;   // [['icon' => 'bolt', 'title' => 'Gyors']]
  • localize:table generates a json column for it. The cast on the owning model does not apply: translated columns live on the translation row.
  • Fallback works as for any column: null falls back, [] is returned as stored. TranslationChanged carries the JSON strings and fires only when the decoded value changed, key order aside.
  • Only array is supported, and a cast on an undeclared column throws InvalidTranslationDefinition. whereLocale() only checks that the column is not null.

Declaration rules

  • The list cannot be empty, and entries must be plain column names (no JSON paths or expressions).
  • id, locale, created_at, updated_at and the foreign key cannot be translatable.
  • Duplicates are dropped. A subclass uses its own #[Localized], or else the nearest parent's; lists are never merged.

Configuration

config/localize.php:

Key Default
locale_length 35 the longest locale key a write accepts; match your locale column
table_suffix '_translations' appended to the singular of the model's table: product_translations
locales ['en'] the locales Route::localized() accepts in the URL; writes are not restricted by it

Good to know

  • Reading is explicit by default. $product->name is not translated for you unless the model opts in with #[Localized(..., magic: true)], and the model's own toArray() is untouched either way.
  • Eloquent events do not fire on the translation rows. Writes go through the query builder; listen to TranslationChanged instead.
  • Insert or update, never upsert. A unique-index clash raises a QueryException and rolls the call back instead of rewriting another model's row. Two requests creating the same locale row at the same moment can hit that too: the index decides, retry the loser.
  • Values are strings or null, except for columns declared with casts: ['features' => 'array'], which take an array (or null). Files and relations are not translatable.
  • Databases: SQLite, MySQL and PostgreSQL run in CI. MariaDB is not tested and not assumed to behave like MySQL.

Testing

composer test
composer analyse
composer lint

The suite uses Pest, runs on SQLite through Orchestra Testbench and requires PHP 8.4+. Static analysis runs at PHPStan level max, and Rector (composer test:refactor) and Pint keep the style. To run against another database: DB_CONNECTION=mysql DB_DATABASE=localize DB_USERNAME=root composer test.

Changelog

See CHANGELOG for what has changed recently.

Contributing

See CONTRIBUTING for details.

Security

If you discover a security issue, please email mate@vortech.hu instead of using the issue tracker.

Credits

  • Mate Papp, Developer @ Vortech

License

The MIT License (MIT). See the License File for more information.

Vortech