Skip to content

Languages

Namespace Hilms\Languages. An installation speaks as many languages as it says it does, and it says so in the panel rather than in a file. This chapter is the developer’s side of that; docs/install/languages.md is the operator’s.

Two kinds of translation live here, and they are not the same thing:

  • The interface — every phrase the code says — is translated in files and, where an installation disagrees with a file, in the database.
  • Content — a course, a lesson, an informational page — is written again in the other language as a row of its own. A course in Polish and the same course in English are two courses, each with its own sections, lessons and files, linked by a translation group. Nothing about a lesson is ever a field per language.
Piece Where
The list and the registry src/Models/Language.php, src/Support/Languages.php
The panel src/Filament/Resources/Languages, src/Filament/Pages/ManageTranslations.php, src/Filament/TranslationsSection.php
Prefixed addresses src/Support/InstallationLocalizer.php, src/Http/Middleware/*, Route::localize() in each module’s route file
Content per language src/Contracts/Translatable.php, src/Concerns/HasTranslationGroup.php
The phrase editor src/Translation/{OverridingLoader,Overrides,PhraseCatalogue}.php, src/Rules/KeepsPlaceholders.php
The switch and hreflang src/Support/Alternates.php, themes/hilms/views/components/ui/language-switch.blade.php
A menu per language Hilms\Navigation\Enums\MenuArea::key(), Support\MenuLocations (Navigation)
A person’s language users.locale, src/Http/Middleware/{PersistUserLocale,UsePreferredLocale}.php
What a language holds src/Contracts/LanguageUsage.php, src/Support/LanguageUsages.php
The clock the installation keeps src/Settings/RegionSettings.php, src/Support/{Region,RegionTimezone}.php, src/Filament/Pages/ManageRegion.php (Time and clocks)

languages sits between access and theme: every page reads the list, so it must be below the theme kernel, and it needs roles and EntityPermissions, so it must be above access. The direction is therefore access → languages → theme → blocks → ….

It also keeps the clock the installation reads its dates on, because which clock an installation keeps is the same kind of answer as which languages it speaks, and the Region and time section sits beside the Languages one in Settings. That half has a chapter of its own — Time and clocks — and it is worth reading, because the shape here is the shape there: access keeps users.timezone and asks upwards through a contract with a floor underneath it.

Two arrows are avoided rather than drawn:

  • access keeps users.locale but does not import languages. It asks for the list through Hilms\Access\Contracts\SpokenLanguages; languages binds InstallationLanguages over the table and ConfiguredLanguage is the floor underneath it, the configured locale alone.
  • languages does not import the modules that write in a language. Each of them registers a Hilms\Languages\Contracts\LanguageUsage on the LanguageUsages registry from its own provider (CourseLanguageUsage in catalog, PageLanguageUsage in pages), which is how the Languages page says “3 courses” without knowing what a course is.

languages is one row per language: code (a BCP-47 tag, unique), name (what the language calls itself), enabled, is_default, position. Exactly one row is the default. The model is sortable, audited on all four columns, and forgets the cached snapshot on saved and deleted; a drag in the table is one query-builder update that fires no model event, so the resource forgets it too (afterReordering(Languages::flush(...))).

Nothing reads the model on a request. Hilms\Languages\Support\Languages does: all(), enabled(), codes(), enabledCodes(), default(), name(), has(), isEnabled(). It is a cached snapshot of scalars under the key languages, exactly as Branding::current() is, because a cached Eloquent graph comes back as an incomplete object (see Conventions). A table that is still empty answers with config('app.locale') alone, so a fresh database, a test and the first minute of an installation all still have a language. A cache that throws anything at all — a stopped or full Redis — is answered from the table the same way, because the list is read while the application boots and a boot that dies on it takes every request and every console command with it (Operations).

Two rules sit on the default language and are enforced where they cannot be bypassed: Hilms\Languages\Actions\MakeDefaultLanguage moves the flag in one transaction and enables the new default on the way, because a language nobody may see cannot be the one / serves; and the delete action is hidden for the default language rather than left to LanguagePolicy, because Shield’s super admin never consults a policy. A language that still holds content is refused with what holds it named.

Only the administrator reaches any of this: nobody is seeded the Language permissions. The resource is a section of Settings, under Site, through InSettings (Settings); Phrases is not configuration but everyday correction, and keeps its own item in the main navigation.

Every visitor address exists twice. niels-numbers/laravel-localizer registers each module’s public routes once behind {locale} (named with_locale.<name>) and once bare (without_locale.<name>):

Route::middleware('web')->group(function (): void {
Route::localize(function (): void {
Route::get('/courses/{course}', [CourseController::class, 'show'])->name('courses.show');
});
});

route('courses.show', $course) needs no locale argument — the request’s language is a URL default — and the default language carries no prefix, so /kurs/… is Polish and /en/kurs/… English on a Polish installation. Fortify’s own route file is loaded inside Route::localize() too, and Hilms\Access\Support\FortifyRoutes hangs each rate limiter on both variants.

Because a route’s real name is prefixed, Route::has('courses.show') is false. Ask Route::hasLocalized() instead, and compare a name with $route->baseName() or Route::currentBaseName().

config('localizer.supported_locales') is the whole locale list, not the installation’s own: it is read once at boot to build the {locale} pattern, and routes have to be static for route:cache and artisan optimize to keep working. What a visitor may actually reach is decided per request, by middleware appended to the web group in bootstrap/app.php:

Middleware Does
ActiveLanguages Pins the enabled languages and the default one on the localizer for this request, and resets both afterwards, so a queue worker never carries them
SetLocale (package) Reads the language off the prefix, else the session, the cookie or Accept-Language, sets the locale and drops {locale} from the route parameters
RejectInactiveLocale 404 for a prefix the routes know but the installation does not serve, so /de/courses never quietly draws the Polish page
PersistUserLocale Remembers a signed-in person’s choice on their account
RedirectLocale (package) Moves a visitor between the prefixed and the unprefixed address — never for a form submission, because a browser downgrades a redirected POST to GET
SubstituteBindings Removed from the web group and appended again behind them, so a binding is resolved once the language is settled

Hilms\Languages\Support\InstallationLocalizer replaces the package’s own service: what it calls the default locale is the default language, and its active locales are the enabled ones. Without it the package answers app.fallback_locale, and a Polish installation would write /pl/kurs for its own language in every URL built outside a request — a console command, a queued notification, a test.

What carries no prefix: the panel, Livewire’s endpoint, /api/v1, Passport and the MCP server, /up and /health, /design, the OAuth callbacks (those addresses are registered at Google and Meta), the signed account-setup link, the signed media.show address and the personal-data download. A signature covers the address it was made for, and a prefix would be a second address for the same file.

<x-ui.language-switch> is a named nav of one link per enabled language, each carrying lang and hreflang, the current one aria-current. In the header it sits beside the colour-scheme toggle as a disclosure (variant dropdown, over <x-ui.dropdown>) whose trigger shows the language being read; below md, inside the menu dialog, it is a plain list (variant list). It draws nothing unless the route is localised and there is more than one language. What an entry shows is the installation’s choice, Branding::languageSwitch(), a Hilms\Theme\Enums\LanguageSwitchDisplay chosen under Settings → Branding: the language’s own name (the default), its code, its flag, or the flag with the name or the code. A flag is a country, not a language, so it is never the whole answer: with the flag alone, the language’s own name is still written for assistive technology (sr-only), and the flags are drawn inline by <x-ui.flag> over outhebox/blade-flags, never fetched. Every link is prefixed, the default language’s included, because the prefix is what carries the choice to the next request; RedirectLocale then strips it and PersistUserLocale has already remembered it.

<x-layouts.base> writes one <link rel="alternate" hreflang> per enabled language, canonically: unprefixed for the default, prefixed for the rest.

Both go through Hilms\Languages\Support\Alternates, a request-scoped map of where this page lives in the other languages. Most pages are the same address under another prefix and the localizer works that out itself (Route::localizedUrl(), Route::localizedSwitcherUrl()). A page whose sibling has an address of its own names that sibling’s route while it renders:

$this->alternates->set($sibling->languageCode(), 'courses.show', ['course' => $sibling]);

A route and not a URL, because the same sibling is rendered twice: canonicalUrl() for hreflang, switcherUrl() for the switch. CourseController and PageController fill it from the siblings a visitor may see.

Hilms\Languages\Contracts\Translatable is the contract and Hilms\Languages\Concerns\HasTranslationGroup the implementation: languageCode(), translationGroup(), siblings(), translationIn(), isAlone(), link(), unlink(), plus the translations() relation and the inLanguage() and alone() scopes. Course and Page implement it. Every new row is a group of one, written in the default language until it is told otherwise, and the unique (translation_group, language) index keeps a group from holding two rows of the same language.

The accessor is languageCode() and not language(). A model method named exactly like one of its columns is read as a relation the moment that attribute is missing, and the accessor then calls itself until the memory runs out.

The editor of a course or a page carries a Translations block in its sidebar — Hilms\Languages\Filament\TranslationsSection, one subclass per module (CourseTranslations, PageTranslations), which answers four questions about its own kind of content: where a sibling is edited, what it is called, how it is written again, and which rows may be linked. The block draws the siblings as links and three actions: “Add a translation”, “Link an existing one” (a row of another language belonging to no group but its own) and “Unlink”. On a create page there is no record yet, so every action is invisible and the section hides itself.

Writing it again is a module’s own action:

  • Hilms\Catalog\Actions\TranslateCourse copies the category, the instructors, the tags, the cover and every section, lesson and block, through Hilms\Blocks\Actions\CopyBlocks, which mints every uuid again and keeps the asset ids, so the translation shows the original’s library files by reference and no byte is copied. A picture’s words are the file’s own per language, so the translated lesson reads them in its own language as soon as somebody writes them on the file. The copy is always a draft: nothing is published in a language nobody has read yet. Its slug is the original’s with the language appended.
  • Hilms\Pages\Actions\TranslatePage copies a page the same way: its blocks (showing the same library files), its template and its audience, as a draft. Every page is an editor’s own, so there is no page this treats differently.

The language field itself is locked while a row has siblings: moving it would move what they are translations of. Both the courses table and the pages table carry a language column and a language filter, drawn only where there is more than one language.

A visitor who lands on a row written in another language is taken to the sibling: CourseController and PageController redirect when translationIn(app()->getLocale()) answers something the visitor may see, and otherwise render the row as it is, in its own language. / renders the page chosen as the home page in the language being read, the default language’s when that language has none, and an unsaved page carrying the site’s own name when nothing is chosen or nothing readable is there yet (see Pages).

courses.language is also what the theme puts as lang on the words the course itself wrote — its title, summary, description, curriculum, card and the player’s article — while <html lang> stays the interface locale (see Accessibility).

Fields that are translated rather than written again

Section titled “Fields that are translated rather than written again”

Some things are one row in every language, because they are labels and not content:

  • A category: name and description are JSON columns through spatie/laravel-translatable, while the slug stays one string generated from the default language’s name — an address is one thing, and the catalogue filters by it. The Categories resource carries lara-zeus/spatie-translatable’s locale switcher, and getTranslatableLocales() answers with the installation’s languages as the page draws rather than as the panel was built.
  • Tags were translatable already (tags.name/slug are JSON): the panel writes the language it is set to, the theme reads the language being read.
  • The two lines an installation says about itself: branding.tagline and branding.footer_text, one input per language on the Branding page. The site name is not translated, because a name is a name.
  • A profile field’s label, one input per language, only the default language’s required.
  • What a library file says: an asset’s alt and description, one field per installation language on its edit page, a language left empty being a translation removed. A picture’s words are read in the language of the record showing it and then the default language’s, and nothing further (MediaAsset::altIn()): words in a third language would say nothing to the reader, and a block that needs them asks for its own (Media).

A field nobody wrote in this language falls back to the code’s language and then to whatever there is (Translatable::fallback(fallbackAny: true)): an empty category name says less than a name in another language.

One trap worth knowing: Branding::current() rebuilds its cached snapshot with setRawAttributes() and never forceFill(), which would push the stored JSON of a translatable line into the language being read.

Correcting a phrase without touching a file

Section titled “Correcting a phrase without touching a file”

The file is the source of every phrase, and translation_overrides is where an installation says something differently — one row per (locale, group, key), where the group is * for the host’s JSON strings, validation for a framework file and catalog::courses for a module’s.

Hilms\Languages\Translation\OverridingLoader extends the framework’s FileLoader and lays those rows over the lines the files gave, key by key: array_replace_recursive over the undotted keys for a PHP group, a plain replace for the JSON group, whose keys are English sentences. It is installed with $this->app->extend('translation.loader', …) and not with a second singleton(): the framework’s translation provider is deferred, so it binds its own loader after every other provider has registered, and an extender is applied to whatever binding wins.

Overrides is the one map the loader reads: every override of every language in one cached array, memoised per process, because the loader is asked for one group at a time and a cache read per group would be a round trip per group. It answers with nothing while it is being read — reading it needs Eloquent, which may need translations — and while the table does not exist yet, so the first hilms:install still speaks. It reads the table when the cache throws anything at all, because the panel translates its labels while it registers: with Redis stopped, an override map read only through the cache killed the application before a request had begun. An error page drawn with the database gone asks the table once, is refused, and draws from the files.

PhraseCatalogue is the list the panel shows: the host’s JSON keys, the framework’s files as laravel-lang installed them, and every namespace the loader knows — a module’s own, the panel’s, any package’s — read from their English files, because English is the code’s language. It is cached per language with the overrides laid on top as it is read, so correcting a phrase costs no rebuild and optimize:clear is what drops the list when the code changes.

ManageTranslations (the admin-only “Phrases” page) narrows that list by language, by where the phrase comes from and by state, and searches the key, the English and the text. It is a custom-data table, so each row is an array rather than a model and a write calls resetTable(). “Correct” writes a row through the model — never the query builder, which fires no event and would leave the cached map holding the old text — and Hilms\Languages\Rules\KeepsPlaceholders refuses a correction that drops a :placeholder.

Nothing in lang/ or vendor/ is ever edited, which is why an upgrade brings new and corrected files without touching what an installation decided to call things.

A course listing lists every language at once — a visitor who reads two of them should see both — and is narrowed by an aside of filters, with the search field as its first item. The shape is the point, because language is only its first consumer:

interface Filter
{
public function key(): string; // the query-string key and the Livewire property
public function label(): string;
public function options(Builder $scope): Collection; // FilterOption values with counts
public function apply(Builder $query, array $selected): void;
}

FilterSet composes LanguageFilter and CategoryFilter, and the courses-list block lets an editor leave either out of a listing. Each filter counts with one grouped query over the whole published listing, so an option says how many courses it would leave rather than how many are left now, an option nothing is behind is not offered at all, and a query-count test holds the number steady from two options to ten. A language is any number of choices and a category is one, which the component says (toggleLanguage, selectCategory) so the view only has to draw it.

The aside lives inside the Livewire component, not in the layout’s rail slot: Livewire binds wire:click only inside its own root element, and a layout slot is rendered outside it. Narrowing the results dispatches hilms:focus to them, because they are further down the page.

users.locale is the language a person reads in, empty until they say. User implements HasLocalePreference, so preferredLocale() — their own language, else the installation’s default — is what every queued notification renders in.

It is written in four places and nowhere else: the Profile card on /account, the user editor in the panel, the panel’s own user menu (“Language”, offered only when there is more than one), and hilms:user:create --language=. PersistUserLocale adds a fifth on the way past: opening a prefixed address is a choice worth remembering.

The panel carries no language in its addresses, so UsePreferredLocale sets the locale from the person reading it. A Livewire component keeps the locale it was first rendered in, and a panel that set none would draw its modals in whatever language the last front-end page happened to be in. The words of a date follow the locale on their own — isoFormat reads Carbon’s, which the framework keeps in step with the application’s — and a test says so. Which hour it names is a separate question with a separate answer (Time and clocks).

Words, numbers and dates in the reader’s language

Section titled “Words, numbers and dates in the reader’s language”

Every phrase the code asks for exists in both languages, and a test proves it rather than a reviewer: tests/Feature/TranslationKeysTest.php reads every literal key handed to __(), trans(), trans_choice() or Lang::get() with PHP’s own tokenizer — in the PHP files, and in Blade as the framework compiles it — and asks the translator for each in English and Polish; a module’s or the framework’s key must exist in both, an English sentence must exist in lang/pl.json. It also asks every enum the panel labels for each case’s words. A key built at runtime ("ops::guide.{$words}.heading") is not a literal and is held by the tests of the code that builds it. Four Packagist tools were weighed against it and none fits this layout: one knows only JSON phrases, one writes into the language files, one repeats the parity test, one was last released as a development version.

Polish counts in three forms — 1 plik, 2 pliki, 5 plików — wherever English counts in two, and every plural phrase is fetched with trans_choice(). Ranges cannot say Polish: [2,4] :count lekcje|[5,*] :count lekcji writes “22 lekcji” where Polish says “22 lekcje”, and four phrases did exactly that on every course card with that many lessons. A form that spells out its own number ({0} Brak lekcji) goes after the three, because Laravel strips the conditions and keeps the form in its place, so one in front would shift every index by one. The parity test checks the grammar itself: for every Polish plural phrase, 22–24 must take the form of 2–4, and 12–14, 21 and 25 the form of 5.

Polish addresses the reader without assuming a gender. “Jesteś zapisany”, “Ukończyłeś kurs :course” and “Gotowy, żeby zacząć?” read as addressed to a man; they are written “Masz dostęp do kursu”, “Kurs :course ukończony”, “Zaczynamy?”. The framework’s own phrases, which laravel-lang words, stay as they come.

Numbers and sizes follow the language. Number keeps a locale of its own, English, so “1.5 MB” stood under every upload field for a Polish reader. LanguagesServiceProvider sets it to the application’s locale at boot and on every LocaleUpdated, as Carbon’s own provider does for dates, and no caller passes a locale of its own: “1,5 MB” beside Polish words, “1.5 MB” beside English.

Dates in the panel are written in the language’s own order — Time and clocks has how.

Refusals an administrator reads come from the modules’ words, not sentences built in code: a bucket setting, a style’s activation and a file move each say why in the reader’s language, a bucket refusal naming the field the way the guide labels it. What a storage provider answers, and what a token file’s parser reports, stays in the words they come in.

  • The assistants. Hilms\Ai\Support\WrittenIn opens every prompt with “The course is written in English; write in that language.”, naming the language as the installation named it, because three words of notes are not enough to guess from (see AI assistants).
  • The API and the MCP server. A course carries its language, its translation_group and its translations; ?language= narrows the listing; a grant or a revoke takes include_translations; create-course takes language and translation_of (see API and agents).
  • Reserved page slugs. Every locale a prefix may name is reserved, because a page called en would sit behind the English home page (see Pages).
  • The destinations. What the application links to is a translation group rather than a row, so the link follows the language being read and falls back to the default language’s (Pages).
  • Menus. A place is one area in one language (header.pl), and a language with no menu of its own reads the default language’s. The snapshot remembers which language it was really read from, so the list carries lang when those words are not the page’s (Navigation).
  • The installer. SeedLanguages creates the one language an installation ships with, from APP_LOCALE, and never touches the table again. Framework strings for a new language are a developer’s job: php artisan lang:add de writes lang/de/ and it is committed.

app-modules/languages/tests/Feature covers the registry and its empty-table fallback, the Languages resource including the default-language guards and the in-use refusal, the prefixed routes and the inactive-locale 404, the switch and its hreflang pair, translation groups and the two translate actions, a person’s language, and the phrase editor including a correction, a reset and a refused placeholder. app-modules/navigation/tests/Feature covers the places per language and the fallback to the default one’s menu. tests/Feature/TranslationParityTest.php keeps the files honest: every module’s pl file holds exactly the keys its en file holds, in both directions, every English file has a Polish one beside it and nothing more, and lang/pl.json really translates rather than restating its keys; TranslationKeysTest, NumberLocaleTest and PanelDatesTest hold the rest of what is above.

Two traps cost an afternoon each:

  • Symfony’s test request carries Accept-Language: en-us, so a test that wants the default language has to say so.
  • Route::localize() is a macro the package registers in its own boot(), while internachi/modular loads module routes from an app.booting callback — which is why the package is registered and booted from LanguagesServiceProvider::register().

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.