Skip to content
MCG Filament CMS Demo

Multilingual sites

This document 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 in this document applies until cms.scoping.locales_enabled is set to true.

Multilingual content and panel translation

Multilingual content and panel translation are two separate features:

  • Multilingual content, the subject of most of this document, serves a page's content in several languages on the public site.
  • Panel translation shows the admin panel's labels in the editor's language. Panel strings follow App::getLocale() and need none of the configuration in this document. It is described in Translating the package's own strings.

A French editor can work in a French panel on a site that serves only English pages, and an English editor can edit French pages in an English panel. To keep the two independent, Locales::current() reads the content locale from a request attribute set by the DetectsCmsLocale middleware, not from the application locale.

Enabling multilingual content

One-time setup

  1. In config/cms.php, set the three keys in the scoping group:

PHP

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

Plain text

CMS_LOCALES_ENABLED=true
CMS_DEFAULT_LOCALE=en-us
CMS_LOCALES=en-us,fr-ca,fr-fr,es-mx,es-es

CMS_LOCALES is a comma-separated list. Each value is trimmed, so spaces after the commas are allowed.

  1. 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 in The locale backfill.

Shell

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; conflicts are described in The locale backfill. 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.

  1. In the same terminal, clear the cached configuration so the new values take effect:

Shell

php artisan config:clear

Repeat this step every time any of the three settings changes.

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:

PHP

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

The locale backfill

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 translationgroup 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

  1. In the admin panel, open Pages under Content Manager.
  2. 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.
  3. 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.
  4. 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.

When the chosen locale holds a deleted translation

A soft-deleted translation still occupies two unique indexes on content: (translation_group, site_id, locale) and (site_id, locale, path). While it exists, no second page can join the translation group in that locale. Before 0.82.1 the Translate action reported this as "This page already exists in :locale, at :path." and the panel offered no way to restore or permanently delete the deleted page.

ContentTranslator::translate() now throws Mcg\FilamentCms\Exceptions\DeletedTranslationExists for this case, a subclass of RuntimeException carrying the deleted page as $translation, with the message "A deleted :locale translation exists at :path." A live translation still throws a plain RuntimeException with the original message.

In the panel, the Translate modal shows that sentence and a Restore it / Replace it choice as soon as such a locale is selected:

  1. Restore it restores the deleted translation through Mcg\FilamentCms\Support\ContentTrash::restore() and opens it. Nothing is copied from the source page.
  2. Replace it calls translate() with replaceDeleted: true, which permanently deletes the deleted translation through ContentTrash::forceDelete() and then creates the new translation, in one database transaction. If the translation is then refused, for example because the parent is untranslated, the permanent delete is rolled back.

The choice is shown only to a user who holds cms.content.delete over the deleted page. The deleted page can also be restored or permanently deleted from the content list's Deleted records filter; see the author guide.

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:

  1. It copies the blocks and the SEO fields, so the translator edits the original text in place.
  2. It adds the new page to the source page's translation group, which is what the language switcher, hreflang tags and API translations field read.
  3. 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.

Over MCP

Translate a page over MCP with cms_translate_content. Do not use cms_create_content with a locale argument.

cms_translate_content calls the same ContentTranslator as the panel's Translate action, so the new page joins the source page's translation group and is placed under the translated parent. The tool also accepts the translated title, slug, blocks, seo and other attributes, and writes them as the translation's first version in the same database transaction. When slug is omitted, it is derived from the translated title, so the URL is in the target language. The result is a draft until cms_publish_content is called with the returned id.

When the target locale holds a soft-deleted translation, the tool returns an error that states the deleted page's path and content id, and names the two calls that resolve it: cms_restore_content with that id, then cms_update_content to replace the restored page's text. The tool has no parameter that permanently deletes the old translation. Every other MCP write can be undone through version history or cms_restore_content, and a permanent delete cannot, so it is available only in the admin panel.

The locale argument on cms_create_content sets the locale column and nothing else. A page created that way belongs to no translation group: the language switcher does not link to it, the translations field of API and MCP payloads does not list it, and no warning is shown. See MCP.

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.

A link whose target has no translation in the locale being served is left unchanged. Rewriting it would point the reader at a French URL that returns

  1. Untranslated sections therefore stay reachable as English links while a

site is translated one section at a time.

LocaleLinks does not change these links:

  • absolute URLs and protocol-relative URLs
  • links to files
  • links to panel paths
  • links that already carry a locale prefix

The last rule stops a link that a block already resolved through Content::publicUrl() from being prefixed a second time.

Results are memoized for the duration of one request, because a page commonly links to the same pages many times and the answer cannot change within one response. On the default locale and on a single-language site, LocaleLinks changes nothing.

The language switcher

Add the switcher to a layout with this component:

Blade

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

The current page The entry links to
Exists in that language The current page's translation
Does not exist in that language That language's homepage, marked as a fallback
Does not exist in that language, and that language's homepage is missing or unpublished Nothing. 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.

Flags

A flag is shown only for a locale with a region subtag. fr-ca shows the Canadian flag. A bare fr shows no flag.

A flag represents a country, and a language is spoken in many countries. Showing the French flag for fr would misrepresent the site to French readers in Quebec, Senegal and Belgium. To show a flag for every language, configure every locale with a region, for example fr-fr and fr-ca instead of fr.

The trigger shows the flag with the locale code as a title tooltip. The code is also present in the DOM inside an element with the cms-visually-hidden class, so screen readers announce the code as the button's accessible name. A locale with no flag shows its code instead of a flag.

Windows renders flag emoji as the two letters of the region code unless a font containing the flag glyphs is loaded. See Pitfalls.

Other parts of the site

  • hreflang and the sitemap. An hreflang tag is emitted for every published translation of the page. The sitemap includes xhtml:link alternates 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.
  • 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 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_locale is changed to en-us first, every row still stored as en matches nothing, and those pages return 404.
  • If the rows are changed to en-us first, they stop matching while default_locale is still en, and those pages return 404.

No order of the two changes avoids a period of 404s. Ship the config change and a migration in the same deploy, so the period lasts only as long as the deploy. To avoid it entirely, put the site in maintenance mode with php artisan down before the deploy and run php artisan up after the migration has run.

Steps

  1. In config/cms.php (or .env), change the locale's spelling in locales, and in default_locale if it is the default.
  2. In a new migration, update the two tables that carry a locale:

PHP

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.

  1. Deploy steps 1 and 2 together, then run the migration.
  2. In a terminal at the site's root, clear the page cache:

Shell

php artisan cms:cache-clear

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 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 cmscreatecontent 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 cmstranslatecontent.

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.

PHP

'panel' => [
    'forms' => [
        'field_name' => 'Field name',
        'field_name_help' => 'The key answers are stored under. …',
    ],
],

PHP

TextInput::make('name')
    ->label(__('cms::cms.panel.forms.field_name'))
    ->helperText(__('cms::cms.panel.forms.field_name_help'))

The file has five top-level groups:

Group Contents
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
mail Email text

Adding a translation

  1. In a terminal at the site's root, publish the language file:

Shell

php artisan vendor:publish --tag=cms-lang

This writes lang/vendor/cms/en/cms.php.

  1. In the lang/vendor/cms/ directory, copy the en directory once per language, for example to lang/vendor/cms/fr/cms.php.
  2. In each copied file, translate the values. Keys that are not translated can be deleted; see the next section.

How a key is resolved

Two separate fallback rules apply:

  1. **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 published lang/vendor/cms/en/cms.php therefore needs to contain only the keys being changed; every other key still comes from the package. This is how to reword one helper text without maintaining a copy of the whole file.
  2. A key missing from the current locale falls back to fallback_locale. The package ships only English. For fr there is no package file, so the published fr file supplies the keys it contains, and every missing key falls back to en. A partly translated file shows the translated strings where they exist and English everywhere else. It never shows a blank or a raw key such as cms::cms.panel.forms.body.

Setting the locale

In the panel, the package reads app()->getLocale() and never sets it. Locale selection is left to the application, 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:

Shell

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.