Cogeze
Core

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:

php
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#

php
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:

php
'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#

blade
<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:

html
<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.