Appearance
Internationalization
Internationalization ships as laraspring/i18n, a Composer package your application installs. It owns one question, "which language is this request in", and the machinery that follows from answering it: a locale per user, an endpoint to change it, a picker on the settings and sign-in screens, and the kit's own strings in English and Spanish.
There is no i18n library in the browser. Laravel already decides the locale, already merges the fallback and already owns the files; what is left for React is looking up a dotted key in an object it was handed. That is a forty-line hook, and it means one set of translation files serves your emails and your screens at once.
What you get out of the box
| Feature | Where |
|---|---|
| The locale contract every package asks | Laraspring\Core\Contracts\LocaleResolver |
| Session for guests, a column for signed-in users | SessionLocaleResolver |
| The locale applied to every web request | SetLocale, on the web group |
| The one endpoint that changes it | locale.update |
| A language screen in settings | language.edit |
| A discreet picker on the guest screens | components/language-switcher.tsx · LanguageSwitcher.vue |
| Translations in the browser | hooks/use-translations.ts · composables/useTranslations.ts |
| Every kit string in English and Spanish | lang/ in each package |
| Email in the recipient's language | HasLocalePreference on User |
Where the locale comes from
LocaleResolver lives in laraspring/core, next to TenantResolver, and for the same reason: more than one package has to ask, and none of them may decide.
No package reads the locale out of the session or off the user itself. It resolves the contract and asks. A second place that decides what language a request is in is a page rendered half in each.
laraspring/core binds ConfigLocaleResolver, which answers with app.locale and offers nothing else, so an application that will only ever speak one language resolves the contract happily and pays nothing for it. Installing laraspring/i18n replaces the binding with one that reads, in order:
- The session, which is what a guest has. Somebody switching to Spanish on the sign-in screen has no account to hang the choice on and still expects the next page to be in Spanish.
- The column on the user,
users.locale. This is what survives: it follows them to another browser, and it is the only copy a queued job can read, because a notification sent an hour later runs with no session near it. - The configured default, and failing that
app.locale.
A stored locale that is no longer offered is ignored rather than used, so removing a language from laraspring-i18n.locales is enough to stop serving it.
Signing in reconciles the two
A user who has chosen a language before gets it back, whatever the browser and whatever a guest picked in this session. One who has never chosen keeps what they picked as a guest, and it is written to their row so the next email finds it: somebody who switched to Spanish to read the sign-in screen has said everything they need to say about which language they want.
The column belongs to your application
users is the edition's table, so the migration adding locale is the edition's too; laraspring/i18n is told the column's name through laraspring-i18n.user_column and does no more than read and write it. It is nullable, and null is the ordinary case: it means "has never chosen", which is different from having chosen the default. Somebody with null follows whatever the installation's default is, including when that default changes.
locale is deliberately absent from $fillable. It is written by locale.update, after the value has been checked against what the installation offers, and never by a form.
Configuration
php
// config/laraspring-i18n.php
'locales' => [
'en' => 'English',
'es' => 'Español',
],
'default' => env('LARASPRING_I18N_DEFAULT'),
'fallback' => env('LARASPRING_I18N_FALLBACK', 'en'),Labels are written in their own language on purpose. Somebody who has landed in a language they cannot read has to find their own in the list, and "Español" is legible to them where "Spanish" is not.
fallback is where a missing key is looked up instead, and it should stay at the language the kit is written in. A key missing from a translation then shows the English sentence rather than the key itself, which is a bad translation but still a readable screen.
Changing the language
One endpoint, locale.update, and one on purpose. The resolver deliberately does not validate, so this is the single place a code from a request body is measured against what the installation actually offers. A second route free to write the locale would be a second route free to forget the check, and without it a session ends up holding whatever was posted while every later request asks the translator for files under a name a stranger chose.
It sits behind web rather than auth, because the picker that matters most is the one on the sign-in screen: that is where somebody who cannot read the interface arrives.
The picker
LanguageSwitcher draws in two shapes:
tsx
<LanguageSwitcher /> // rows, for the settings screen
<LanguageSwitcher variant="compact" /> // a globe, for the guest screensIt draws nothing at all when the installation offers one language, or when laraspring/i18n is absent and the route is therefore missing. Same rule as the organization switcher: a control behind a feature flag must never be left pointing at a 404.
The settings entry arrives through the declarative nav in layouts/settings/nav.ts, which now carries a translation key rather than a title. A route name is true in every build; a title is not.
Email
Every notification the kit sends goes out in the recipient's language, not in the language of whoever caused it to be sent. An administrator working in English who invites a colleague set to Spanish sends a Spanish invitation, and neither of them had to think about it.
That works because App\Models\User implements Laravel's HasLocalePreference:
php
public function preferredLocale(): ?string
{
return $this->locale;
}Laravel asks every notifiable this before it renders, including from a queue an hour later, where the session that knew the sender's language is long gone.
The invitation is the one exception. It is addressed to an email rather than to a user, because the person being invited may not have an account yet, and an address has no preference to give. The approximation is the inviter's language: usually right, because colleagues invite colleagues, and easy to correct once the recipient arrives and switches. An edition that wants better listens for MemberInvited and sends its own.
See Add a locale for translating the kit into a third language, and Translate your features for the strings you write yourself.