Languages and translations
How the language of a page is chosen, how strings are written and translated, and how to add a language.
The kit ships in Spanish and English. A visitor picks a language with a switcher, an account carries its own, and every string a person reads is written once in English and translated by a command. This guide covers the configuration, the resolution order, the four lang:* commands and the AI provider behind them, and the steps to add a third language.
The configuration
All of it is in config/app.php.
| Key | Value | What it does |
|---|---|---|
languages |
['es' => 'Spanish', 'en' => 'English'] |
The languages the application publishes. The key is the locale code, which must match a lang/{code}.json file; the value is the label shown in the switcher and in the profile, passed through __() so it is itself translatable. |
fallback_locale |
es |
The language when nothing else decides, and the one Laravel falls back to when a string has no translation in the active language. |
locale |
env('APP_LOCALE', 'es') |
The language outside a web request: console commands, queued jobs and API routes. A web request never reads it; the middleware below sets the language on every one. |
How the language of a page is chosen
App\Http\Middleware\LocaleMiddleware runs on every web request and stops at the first rule that answers:
- A signed-in account with a
localeon its profile gets that language on every device, whatever the browser says or the visitor picked before signing in. - A visitor who used the switcher keeps that choice for the rest of the visit. It lives in the session under
locale. - Otherwise the browser decides. The
Accept-Languageheader is read in the order the browser lists its languages, each reduced to its two-letter code (es-MXises), and the first one that appears inapp.languageswins. A browser that asks for French and then English gets English, not the fallback. - Nothing matched:
fallback_locale.
An account gets its locale at registration: App\Observers\UserObserver copies the language the registration page was being read in, so the first preference is captured without asking. The person changes it later at /account, in the "Language and time" card, from a select fed by app.languages.
The help centre is the exception: its addresses carry their language, /help/en/... and /help/es/..., and that language wins there for everyone. Reading a help page in another language changes nothing in the profile or the session, so the rest of the application keeps the language these rules chose. See Write guides and the changelog.
The language switcher
<x-language-switcher /> renders one small link per language in app.languages, highlighting the active one. It is shown in the header of the public site, which is the landing and the feature pages, and in the header of the help centre. Outside the help centre it renders nothing for a signed-in account, because that account reads its profile language on every device and the switch would promise something it would not keep. The application layout has no switcher for the same reason.
Each link points at /locale/{code}, a route that checks the code against app.languages, answers 404 for any other, stores the code in the session when the visitor is not signed in, and redirects back to the page they were on.
In the help centre the switcher receives the current page in every language it exists in, and each link goes straight to that page: a guide leads to the same guide in the other language. It is shown to every reader, signed in or not, because it only navigates and stores nothing, and a language the page is not written in is not offered.

How strings are written
Every string a person reads goes through __() with the English sentence as the key, in Blade and in PHP alike:
{{ __('Save changes') }}
{{ __('Welcome back, :name', ['name' => $user->name]) }}
There are two kinds of translation file:
lang/{code}.jsonholds the sentences. The key is the English text and the value its translation;lang/en.jsonmaps every key to itself. This is where almost everything lives.lang/{code}/*.phpholds structured, dotted keys:auth.php,passwords.php,validation.phpandpagination.phpfrom Laravel, plusweb.phpwith a few notices. They are read with the dotted key,__('web.ios_notice'), and their nested arrays are kept in sync like the JSON. The terms and privacy text live one folder down, inlang/{code}/legal/, one file per project, and you write each language by hand: see /help/write-guides-and-changelog.
The rule is that no Spanish, and no other language, appears in code. Write English, run the commands, and the translations follow.
The commands
php artisan lang:search
Scans app/, resources/views/ and routes/ and adds every string it finds to lang/en.json, with the key as its own value, when the key is not there yet. It recognises:
__(),@lang(),trans(),trans_choice()andLang::get()with a single- or double-quoted string.- The label of an admin input,
Inputs\Text::make('Label'), except for relationship inputs such asBelongsToorHasMany, whose label is a model name. ->setTitle('Title')on inputs and filters.public $title = 'Label'on admin actions.- The singular and plural name of every admin resource in
app/Front/Resources. - The headline form of every case of an enum in
app/Enumsthat usesIsEnum, since that is the label shown for it. - The headline form of every limit key declared in
App\Classes\PlanLimits, since that is the title the usage cards draw. A limit you add later is picked up on its own. - The name and the description of every help centre category in
config/help.php, and the subtitle under the guides,help.guides_description. - The
titleand thesummaryof every feature declared inresources/views/landing/{your-project}/features.php, including one whose page you have not written yet. You do not add those tolang/en.jsonby hand.
A key that contains a dot, has no space and is all lower-case is treated as a dotted key of a PHP file and skipped. Run it after adding text to the interface.
php artisan lang:sync
Makes every language hold the same keys as English, in the same order, for the JSON file and for every PHP file under lang/en/:
- A key found in another language and missing in English is added to English with the key as its value.
- Every other language is rewritten in the order of the English file. A key it already had keeps its translation; a key it lacked is translated by the AI provider, or copied in English when no provider is configured.
- A language with a
lang/{code}.jsonfile or alang/{code}/folder is detected automatically, so a new file is created for a language that has none yet.
Run it after lang:search, and once when you add a language.
php artisan lang:delete
Removes keys nobody uses from every lang/*.json. A key is kept when lang:search would find it, or when it appears as a literal string in any .php, .js, .jsx, .ts, .tsx, .vue, .html, .htm, .json, .md, .yaml or .yml file of the project, vendor/ included along with the packages linked into it, outside tests/, docs/, node_modules/, storage/, bootstrap/cache/, public/build/ and lang/. A key with a line break or quotes also counts when the code writes it with escapes such as \n or splits it into strings joined with .. Empty keys are always removed. The PHP files are never touched, and a file with nothing to remove is not rewritten. Run it after deleting interface text.
php artisan lang:update
Runs lang:sync, then lang:search, then lang:sync again. It is the one command to run after a batch of interface work: it collects the new strings and translates them.
Automatic translation
lang:sync translates through App\Services\LangTranslatorService, which prompts App\Ai\Agents\TranslationAgent on the provider named by config('ai.default'). That is openai in config/ai.php, with its key from .env:
OPENAI_API_KEY=sk-...
For each missing string, one request is sent with two parts: an instruction that says to translate Laravel application text into the target locale, keep the meaning and tone, preserve placeholders exactly and return only the translated text; and the string itself, with every :placeholder swapped for a marker such as __PH0__ before sending and swapped back after, so a variable name is never translated. The model is the provider's default text model, gpt-5.4 for OpenAI, unless you set models.text.default under the provider in config/ai.php.
When OPENAI_API_KEY is empty the command does not fail: it prints AI translation provider is not configured once per string, writes the English text as the value, and moves on. The same happens when a request fails or returns nothing. A translation that came back in English is therefore a string to translate by hand or on the next run with a key.
Any provider in config/ai.php works when you change ai.default; the Ollama driver needs no key. Setting one up is in Connect an AI provider.
Adding a language
-
Add it to
config/app.php:'languages' => [ 'es' => 'Spanish', 'en' => 'English', 'fr' => 'French', ], -
Add the new label to
lang/en.jsonby hand, as"French": "French". The label is passed through__()in the switcher and the profile, but the only configurationlang:searchreads is the help centre's, notapp.languages, so it will not find it for you. -
Run the sync. It creates
lang/fr.jsonandlang/fr/*.phpwith every key translated, or copied in English when no provider is configured:php artisan lang:sync -
Translate the help centre. Every guide and changelog entry expects a copy at
docs/{your-project}/help/guides/fr/{slug}.md; a guide without one does not exist in French, and its French address answers 404. The check that finds what is missing readsapp.languagestoo:php artisan help:stamp --check
From that point the switcher shows FR, the browser rule accepts fr, and the profile select offers French.
How the help centre is translated
The guides you are reading are Markdown files under docs/{your-project}/help/, with the English at the top level and each translation in a folder named after the locale, es/ for Spanish. The lang:* commands do not touch them: the translation is written by hand or by an assistant, and php artisan help:stamp writes a fingerprint comment in each translated file that records which English it was written from. help:stamp --check then lists every document with no translation, one whose English changed after the translation was written, and one whose English no longer exists. Where the files go and how a derived project owns them is in Make it your own.