Download the PHP package vortech/laravel-localize without Composer
On this page you can find all versions of the php package vortech/laravel-localize. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download vortech/laravel-localize
More information about vortech/laravel-localize
Files in vortech/laravel-localize
Package laravel-localize
Short Description Typed, per-model translation tables for Eloquent: a locale view with exact fallback rules, transactional bulk saves and a unique validation rule.
License MIT
Homepage https://github.com/Vortech-Group/localize
Informations about the package laravel-localize
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
localize:install publishes config/localize.php. The service provider is discovered automatically.
Usage
1. Declare the model
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
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).
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_suffixin the config. For one irregular model overridegetTranslationTable()andgetTranslationForeignKey().
3. Read
Fallback works per column. With hu as the app locale:
| 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:
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
| 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:
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:
- 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, orlocale()combined withlocaleFrom(), 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()andwhereNotNull()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:
To load fewer rows, name the locales you read. The app locale you fall back to is always included:
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:
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
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.
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:
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:
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:
- 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. SetLocalecallsapp()->setLocale(), solocale(), 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, androute('products.show', [$product, 'locale' => 'en'])links to another one. - The
localeparameter is removed from the route, so controllers andRequest::route()->parameters()never see it. - Using
SetLocaleon a route without a{locale}parameter throwsInvalidTranslationDefinition.
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:
- A slug is generated when a save sets
from, leavestoout, and the locale has no slug yet. Later title changes keep the slug, so URLs stay stable;regenerate: truemakes it follow the title instead. - A slug you pass is stored as given, and an explicit
nullclears 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_BRanden-GBuse 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:
/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.
Two Blade components cover the common case:
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:
localize:tablegenerates ajsoncolumn for it. The cast on the owning model does not apply: translated columns live on the translation row.- Fallback works as for any column:
nullfalls back,[]is returned as stored.TranslationChangedcarries the JSON strings and fires only when the decoded value changed, key order aside. - Only
arrayis supported, and a cast on an undeclared column throwsInvalidTranslationDefinition.whereLocale()only checks that the column is notnull.
Declaration rules
- The list cannot be empty, and entries must be plain column names (no JSON paths or expressions).
id,locale,created_at,updated_atand 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->nameis not translated for you unless the model opts in with#[Localized(..., magic: true)], and the model's owntoArray()is untouched either way. - Eloquent events do not fire on the translation rows. Writes go through the query builder; listen to
TranslationChangedinstead. - Insert or update, never upsert. A unique-index clash raises a
QueryExceptionand 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 withcasts: ['features' => 'array'], which take an array (ornull). 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
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 [email protected] instead of using the issue tracker.
Credits
- Mate Papp, Developer @ Vortech
License
The MIT License (MIT). See the License File for more information.
All versions of laravel-localize with dependencies
illuminate/console Version ^13.0
illuminate/contracts Version ^13.0
illuminate/database Version ^13.0
illuminate/filesystem Version ^13.0
illuminate/http Version ^13.0
illuminate/routing Version ^13.0
illuminate/support Version ^13.0
illuminate/validation Version ^13.0
illuminate/view Version ^13.0
laravel/prompts Version ^0.3