Multilingual sites
This page explains how one Filament CMS site serves the same page in several languages, for example /about in English and /fr-ca/a-propos in Canadian French. It covers enabling the feature, the URL model, creating translations, how links and the language switcher behave, renaming a locale, the known gaps, and translating the package's own interface strings. It is written for the developer who enables the feature and for the developer who maintains the site afterwards.
The feature is off by default. None of the behavior described on this page applies until cms.scoping.locales_enabled is set to true.
Enabling multilingual content
One-time setup
- In
config/cms.php, set the three keys in thescopinggroup:
'scoping' => [
'locales_enabled' => true,
'default_locale' => 'en-us',
'locales' => ['en-us', 'fr-ca', 'fr-fr', 'es-mx', 'es-es'],
],
Locales::all() always includes default_locale and lists it first, even when it is missing from locales. Alternatively, set the equivalent environment variables in the site's .env file, which lets a deployment add a language without editing the published config file. CMS_LOCALES is a comma-separated list. Each value is trimmed, so spaces after the commas are allowed.
CMS_LOCALES_ENABLED=true
CMS_DEFAULT_LOCALE=en-us
CMS_LOCALES=en-us,fr-ca,fr-fr,es-mx,es-es
Reading the locale list from the environment
A config file published before the environment variables existed has a hard-coded locales array. When converting it to read CMS_LOCALES, make the env() default the site's full list of locales, not an empty string:
'locales' => array_filter(array_map('trim', explode(
',',
(string) env('CMS_LOCALES', 'en-us,fr-ca,fr-fr,es-mx,es-es')
))),
With an empty-string default, an environment that does not set CMS_LOCALES resolves locales to []. Locales::all() returns [default_locale] for an empty list, so the site serves only the default language from a database that holds several:
- Every page in a non-default locale returns 404.
- The locale select on the content form, the locale filter on the content table, the locale switch on the page tree and the Translate action are all hidden, because each one is shown only when
count(Locales::all()) > 1. - No exception is thrown and nothing is logged.
The environment variables do not allow one database to be served with a different set of locales in different environments. Each page row stores its locale in the locale column, and a page whose locale is not configured returns 404 rather than falling back to another language. The same mechanism is described under Renaming a locale.
Backfilling existing pages
- In a terminal at the site's root, preview the locale backfill, then run it. The backfill gives every existing page a locale. Run it before the feature is switched on in production; the reason is explained below.
php artisan cms:locales:backfill --dry-run
php artisan cms:locales:backfill
Both runs print the number of rows to update and the locale they will receive, which is default_locale unless --locale is passed. Both also print an error listing up to 10 conflicting paths, if any exist. The dry run stops after printing. The real run asks for confirmation in an interactive terminal, writes the change, and prints a warning if cms.scoping.locales_enabled is still false.
- In the same terminal, clear the cached configuration with
php artisan config:clearso the new values take effect. Repeat this step every time any of the three settings changes.
cms:locales:backfill sets the locale column on rows written before the feature was enabled. Those rows carry locale = '', an empty-string sentinel that means “scoping was not in use when this row was written”.
The default locale matches both its own value and the empty sentinel. A site that switches the feature on before running the backfill therefore still resolves its existing pages. The failure case is a path held by two rows, one with locale = '' and one with the default locale: the database returns either row, and which one is not defined. Before writing, the backfill lists every such path as an error. If the real run is confirmed anyway, the update fails on the unique index over (site_id, locale, path) and no row is changed, so the command cannot leave two rows answering for one path. Resolve each listed path, by deleting or moving one of the two rows, before running the backfill again.
The backfill changes no URL, because the default locale is served without a prefix both before and after. It fires no ContentPathChanged event and creates no redirect; it updates only the locale column. It includes soft-deleted rows, because the unique index over (site_id, locale, path) counts them: a trashed row left at '' would collide with the backfilled copy of itself when it was restored.
URLs
The default locale is served without a prefix. Enabling the feature changes no existing URL: /about stays /about. Every other locale is served under /{locale}, for example /fr-ca/a-propos.
| Aspect | Behavior |
|---|---|
| Public URL | /about for the default locale; /fr-ca/a-propos for any other locale |
| Stored path column | Never includes the locale prefix: /a-propos, not /fr-ca/a-propos |
| Slugs | Translated. A French page has its own slug and its own address |
| Missing translation | Returns 404. The sitemap and hreflang tags list only translations that exist |
| Grouping | Every language version of a page shares one translation_group UUID |
| Region-qualified locales | Supported, for example fr-CA, pt-BR and zh-Hant-TW |
The stored path is the page's identity. The MCP tools, the delivery API, the parent lookup and the redirects table all address a page by path. The locale prefix is added only when a URL is rendered, by Content::publicUrl(). Because the prefix is never stored, enabling the feature does not rewrite any row.
For the same reason, code that compares a request URL with a stored path has to remove the prefix first. The DetectsCmsLocale middleware removes the prefix, stores the result in the cms_path request attribute, and calls App::setLocale() with the detected locale. Locales::path() returns cms_path when it is set, and the raw request path otherwise. Use Locales::path() rather than $request->path() in code that looks up content by path.
DetectsCmsLocale is registered on the frontend routes only. Panel routes are not affected by it, and the router itself is unaware of locale prefixes.
Region-qualified locales
A locale list such as 'locales' => ['en', 'fr-CA', 'pt-BR'] is supported. URLs and language attributes use different letter case, following two different conventions:
- URLs are lower case:
/fr-ca/a-propos. The canonical tag and the sitemap emit only the lower-case form, so a search engine indexes one URL per page. - Language attributes keep the configured spelling:
hreflang="fr-CA"and<html lang="fr-CA">, which is the form BCP 47 and search engines expect.
Incoming prefixes are matched case-insensitively, so /fr-CA/, /fr-ca/ and /FR-ca/ all reach the same page. Only the lower-case form appears in links the package generates. Write each locale in config/cms.php in its BCP 47 spelling. That spelling is stored on the row and shown in the panel.
Creating a translation
- In the admin panel, open Pages under Content Manager.
- Open the row menu of the page to translate: the ⋯ button at the end of its row in the table view, or the page's menu in the tree view. Choose Translate. The action is shown only when multilingual content is enabled, the signed-in user may create content, and at least one configured locale has no translation of this page yet. It is not on the page's edit screen.
- In the modal, choose the target locale in the Language select. It lists only the locales the page has not been translated into, and the first one is preselected. Confirm the modal.
- The panel redirects to the edit screen of the new translation and shows the notification “Translated into :locale”, with the locale code in place of
:locale. The translation starts as a draft copy of the source page's blocks and SEO fields. Translate the text, then publish the draft.
If the parent page has no translation in the chosen locale, the action shows an error notification naming the parent and creates nothing. Translate the parent first.
Do not create a translation by duplicating a page and changing its locale. The Translate action runs ContentTranslator, which does three things a duplicate does not:
- It copies the blocks and the SEO fields, so the translator edits the original text in place.
- It adds the new page to the source page's translation group, which is what the language switcher,
hreflangtags and APItranslationsfield read. - It places the new page under the translation of the source page's parent.
The third point determines the URL. A French child of an English parent placed directly under that parent would be served at /fr-ca/about/equipe, with an English path segment inside a French URL and a breadcrumb trail that changes language partway up. The action therefore refuses to translate a page whose parent has no translation in the target language. Translate a section from the top page down.
Translating the homepage makes the new page the homepage for its locale. Each locale has its own homepage.
A page's language cannot be changed after the page is created. Changing it would move the row across two unique indexes in one update. Use the Translate action to create the page in the other language instead.
The content list in the panel has a column listing every language a page exists in, so untranslated pages can be found without opening them. The page tree shows one language at a time.
Links in translated pages
A link stored in a block is written once and copied unchanged into each translation. A French page created from an English page therefore contains the English link targets, such as /docs. Without correction, a reader of the French page who follows the link arrives on the English page.
LocaleLinks corrects this when the page is rendered. It runs in Block::__construct, after withView(), and rewrites internal links for the locale being served. It walks every value in the block's data rather than a list of named fields, so it reaches structured fields such as cta_url, repeater rows, and <a href> attributes typed into a rich text body. A block added later is covered without being registered with LocaleLinks.
Links are rewritten when the page is rendered, not stored rewritten. If a translator edited each href by hand, every slug change would require the same edit again in every language.
LocaleLinks resolves a link through the translation group: it finds the linked page, then asks that page for its translation in the locale being served. It does not add the locale prefix to the URL. Prefixing /docs to /fr-ca/docs is correct only while the French page keeps the slug it was copied with, and an editor can rename /fr-ca/about to /fr-ca/a-propos at any time. Menu items are resolved the same way.
The language switcher
Add the switcher to a layout with this component:
<x-cms::language-switcher :content="$cmsContent ?? null" />
The switcher renders nothing until at least two locales are reachable. With one reachable locale, its only entry would be the language already being read.
Each entry links as follows:
- When the current page exists in that language, the entry links to the current page's translation.
- When the current page does not exist in that language, the entry links to that language's homepage, marked as a fallback.
- When the current page does not exist in that language and that language's homepage is missing or unpublished, the language is left out of the switcher.
The homepage fallback replaced an earlier behavior in which the switcher linked only to existing translations. Under that behavior the switcher disappeared from every untranslated page, and a visitor reading French who followed a link to an English-only page had no link back to the French site. An unpublished homepage counts as missing, because its public URL returns 404.
The trigger button shows the language being read. The list shows only the other languages, so the first item in an open list is never the current language.
Other parts of the site
hreflangand the sitemap. Anhreflangtag is emitted for every published translation of the page. The sitemap includesxhtml:linkalternates for each translation.- Menus. One menu serves every language. Each menu item resolves to the reader's language when the page is rendered. An item whose target page has no translation in that language is hidden.
- Snippets, tag archives, related pages and search. Each shows only content in the language being read.
The delivery API and MCP
The delivery API and the MCP tools both accept an optional locale argument. When a path exists in more than one language and no locale is given, both refuse the request instead of choosing a row: the delivery API returns 409 Conflict and names the locales it found, and the MCP tools return a validation error that names the locales. Without this rule, an agent asked to edit the English page could edit the French page instead, and the response would not show that the wrong row was used.
When the feature is enabled, API payloads include locale and translations fields. When it is disabled, both fields are omitted, so a single-language consumer receives the same payload as before. See The delivery API.
Renaming a locale
Renaming a locale, for example from en to en-us, takes the affected pages offline unless the configuration change and the data change are deployed together.
Content::scopeInScope() treats the default locale and other locales differently. The default locale matches its own value and the empty sentinel ''. Every other locale matches its own value only. Consequences:
- If
default_localeis changed toen-usfirst, every row still stored asenmatches nothing, and those pages return 404. - If the rows are changed to
en-usfirst, they stop matching whiledefault_localeis stillen, and those pages return 404.
DB::table('content')->where('locale', 'en')->update(['locale' => 'en-us']);
DB::table('redirects')->where('locale', 'en')->update(['locale' => 'en-us']);
Both tables have a unique index over (site_id, locale, path). If a row already exists under the new spelling with the same path, the update fails on that constraint instead of merging the rows. Check for overlapping paths before running it.
Page cache keys include the locale, so entries under the old spelling remain in the cache. They are never served, because no request produces the old key, but they occupy cache storage, and every page is rendered from scratch on its first request after the rename. Clearing the cache in step 4 removes the old entries. See Caching.
The search index does not need rebuilding. Search text is stored on content_versions and carries no locale.
cms:locales:backfill does not rename locales. It fills only empty locale values with the default locale.
Renaming the default locale moves no URLs, because the default locale is served without a prefix before and after the rename. Renaming any other locale changes the URL prefix of every page in that locale, and no redirects are created for the old URLs.
Known gaps
| Gap | Detail |
|---|---|
| Menu items of type url are not resolved per locale | A url menu item is rendered exactly as typed, which is correct for an external link. For an internal link, nothing connects the typed URL to a page, so there is no translation to resolve to. Use a content menu item for internal links. |
| A derived menu's source page is resolved like a menu item's target | The source page is resolved through its translation group. When the source page has no translation in the language being served, the derived contents are empty and the item renders as a plain link instead of an empty disclosure. A section therefore stays out of the header in a language until its source page is translated. Falling back to the source language would put links to English pages inside a French menu. |
| One homepage per locale is enforced in a transaction, not by the database | A partial unique index would enforce it on PostgreSQL and SQLite. MySQL has no partial unique index, and the package supports all three databases. |
| A cached page does not re-resolve its links | A page cached before a linked page was translated keeps linking to the untranslated page until its cache entry is removed. Cache invalidation is triggered per published page, not per page that links to it. Run php artisan cms:cache-clear after publishing a batch of translations. |
| A page created with cms_create_content and a locale is not a translation | The page joins no translation group, so the switcher never links to it, and no warning is shown. Use cms_translate_content. |
Translating the package's own strings
This section covers the package's own interface text in a language other than English: panel labels, block names, helper text and validation messages. It is independent of multilingual content and needs none of the configuration above.
Where the strings are stored
All of the package's strings are in one file, resources/lang/en/cms.php. The service provider registers it under the cms namespace, so every key is referenced as cms::cms.*. The first cms is the namespace and the second is the file name. The file has five top-level groups: blocks (block labels, descriptions and field labels), panel (strings for the content, seo, media, settings, redirects, forms, submissions, menus and versions screens), enums (enum labels), navigation (navigation labels) and mail (email text).
Adding a translation
- In a terminal at the site's root, publish the language file with
php artisan vendor:publish --tag=cms-lang. This writeslang/vendor/cms/en/cms.php. - In the
lang/vendor/cms/directory, copy theendirectory once per language, for example tolang/vendor/cms/fr/cms.php. - In each copied file, translate the values. Keys that are not translated can be deleted.
How a key is resolved
- Within one locale, the published file overrides the package file key by key. Laravel loads the package's file first and merges the published file over it with
array_replace_recursive. A publishedlang/vendor/cms/en/cms.phptherefore needs to contain only the keys being changed; every other key still comes from the package. - A key missing from the current locale falls back to
fallback_locale. The package ships only English. A partly translatedfrfile shows the translated strings where they exist and English everywhere else. It never shows a blank or a raw key such ascms::cms.panel.forms.body.
Setting the locale
In the panel, the package reads app()->getLocale() and never sets it, so the panel uses config('app.locale') or whatever the application's own middleware sets. A per-user panel language requires middleware on the panel that calls App::setLocale(). Publishing the language files alone does not change the panel's language.
On the public site, the package sets the locale in one case: when multilingual content is enabled, DetectsCmsLocale calls App::setLocale() with the locale read from the URL, so a page at /fr/a-propos renders French dates and French package strings. When cms.scoping.locales_enabled is false, the default, the package never sets the locale.
Strings that are not translated
- MCP tool titles and JSON-schema descriptions. These are read by the AI client to decide how to call each tool. Translating them would change the tool contract for the client without changing anything a person sees.
- Filament's own interface text, such as Save, Delete, pagination and table filter labels. These come from Filament. Publish them with
php artisan vendor:publish --tag=filament-panels-translations.
The untranslated-string test
A test walks src/, skipping the MCP directory, and fails when any call to label(), helperText(), placeholder(), modalHeading() or a similar method receives a bare string literal instead of a __() call. Without the test, a block added later with one untranslated label would show that label in English inside an otherwise French panel. The test detects only literals passed directly to those methods. A string built inside a closure, such as the conditional slug helper text in ContentForm, also goes through __(), but the test cannot inspect it, so strings built that way rely on code review.