Internationalisation
Translating content, translating field labels, and serving one page at several URLs.
Two independent layers:
| Layer | Question it answers |
|---|---|
| Content translation | What text does this record show in locale X? |
| URL localisation | Which URL serves this page in locale X? |
A site can use either alone.
Locales#
Active locales live in the languages table, owned by the language plugin. One is marked
default.
This is not config('app.locale'). Laravel's config may say en while the site's
default is vi. Core code asks the plugin through the language.default command and falls
back to config only when the plugin is absent.
Translating content#
A model opts in with HasLocale and declares which fields translate:
use App\Models\Concerns\HasLocale;
class Article extends Model
{
use HasLocale;
/** field => base label */
protected array $translatable = [
'name' => 'Title',
'summary' => 'Summary',
'content' => 'Body',
];
}
Base values stay in the main table. Translations live in a row-shaped overlay table owned by the same plugin — one row per record × locale × field. Reading a field returns the overlay for the active locale, falling back to the base value.
Consequences worth knowing:
- Adding a locale adds rows, never columns. No migration per language.
- A missing translation degrades to the base value rather than rendering empty.
- The overlay table belongs to the plugin, so uninstalling it removes its translations too.
Translatable field labels#
$translatable maps a field to its base label, shown in the admin. Administrators can
override a label per locale in a small config table, separate from the translation data
itself — a handful of rows that rarely change, kept apart from many rows that change often.
Declaring $translatable as a flat list is still accepted, in which case the label is the
field name.
Registering translatable models#
public function translatables(): array
{
return ['article' => Article::class];
}
The translation manager uses this to list what a plugin exposes for translation. It filters
by category() === 'content', so utility plugins are not offered.
URL localisation#
One intent can serve several URLs, one per locale:
'article.index' => [
'route' => [
'pattern' => '/blog',
'locales' => ['en' => '/news', 'ja' => '/oshirase'],
],
…
],
Each locale becomes its own row in route_map and its own registered route, named
hub.{locale}.{intent}. Administrators can edit each independently.
Matching a localised route sets the application locale before needs resolve, so commands
return translated content. Setting it later returns the default language with the wrong
<html lang>.
Linking between pages#
<a href="{{ hub_url('article.index') }}">Blog</a>
hub_url() resolves to the current locale's URL and falls back to the default. Using
route('hub.article.index') directly makes every link on a localised page point back to the
default locale — a visitor who clicks anything leaves the language they were reading.
hreflang#
Alternates are emitted automatically for intents with locale variants:
<link rel="alternate" hreflang="x-default" href="https://example.com/blog">
<link rel="alternate" hreflang="en" href="https://example.com/news">
Without this, two URLs with the same content read as duplicates and the search engine picks one for you — usually not the one you wanted.
Common mistake#
Translating content but not URLs is fine. Localising URLs but forgetting hub_url() in
views is not: the pages exist, are indexed, and every internal link throws the visitor back
to the default language.