Skip to content

Internationalization

Lattice apps are translated with Laravel’s normal translation system. Because you describe your UI in PHP, almost everything a user reads — page titles and headings, field labels and helper text, table headers, action labels, validation messages — is produced on the server, so you translate it exactly like any Laravel app: lang/ files and __() / trans(). There is no Lattice-specific layer to learn for it.

A smaller set of strings renders in the browser — Lattice’s built-in chrome (the rich-editor toolbar, table pagination, filter controls, the bulk bar, and the accessibility labels on menus, toasts, tabs, and selects) and any custom React components you write. Lattice delivers the same translations to the client through an i18next backend, so you keep one set of files in lang/ and never duplicate strings in JavaScript.

Start in the backend; reach for the frontend bridge only for what actually renders in the browser.

Your definitions are PHP, so wrap the text you author in Laravel’s translation helpers, exactly as you would anywhere else:

use Lattice\Ui\Components\Heading;
use Lattice\Form\Components\TextInput;
Heading::make(__('products.title'));
TextInput::make('name', __('products.fields.name'))
->helperText(__('products.fields.name_hint'))
->rules(['required']); // validation messages come from Laravel's validation translations

These render on the server and ship already-translated in the Inertia payload — nothing here touches i18next. The active language is the app locale (App::setLocale()), the same as any Laravel request.

Frontend: the same translations, over i18next

Section titled “Frontend: the same translations, over i18next”

The strings that live in the browser read through i18next, with the English baked into each call site as a fallback — so the UI is fully readable with zero configuration. To replace that English with your translations, you point Lattice’s i18next instance at a backend that serves your lang/ files (below). The instance is isolated from your app’s own i18next setup (if any), so wiring it up never touches your application’s translations.

Every built-in string lives in one i18next namespace, lattice, scoped into a few domain groups — form.* (fields, the rich editor, file uploads), table.* (columns, pagination, filters, bulk selection, row actions), notifications.* (the notification inbox), and common.* (shared chrome: tabs, toasts, tooltips, menus). Keys read like form.editor.bold or table.pagination.next. One namespace keeps Lattice’s keys from colliding with your app’s, and means a single route translates everything Lattice renders.

There is nothing to set up to render in English. Each call site supplies its own default (t("form.editor.bold", "Bold")), and the instance initializes itself the first time a component reads a string. Apps that never opt into a backend never bundle one.

To serve translations, install bambamboole/laravel-i18next, which exposes the routes the frontend reads from:

Terminal window
composer require bambamboole/laravel-i18next

Its routes register automatically. Enable them, and serve namespaces as nested i18next JSON:

config/i18next.php
return [
'routes' => ['enabled' => true],
'namespaces' => true,
'output' => 'nested',
// …see the laravel-i18next docs for the full config.
];

Lattice registers its own lang/ directory under the lattice namespace, so its bundled English and German translations are served at /locales/{lng}/lattice.json automatically — there is nothing to copy or publish to get them.

Lattice reads whether the routes are serving (i18next.routes.enabled), whether missing keys are reported back (i18next.save_missing.enabled), the supported locales from config('lattice.i18n.locales'), and which locales to eagerly preload from config('lattice.i18n.preload_locales'). It shares those values to the frontend as an Inertia once prop.

Locales listed in preload_locales have their translations fetched once at startup (in the background, so the first paint isn’t blocked). Switching to a preloaded locale then resolves from the store instead of an HTTP round-trip, which avoids a flash of the fallback language. Leave it empty to load each locale lazily on first switch.

Lattice shares an i18n block under lattice.i18n, and createLatticeApp wires the whole frontend from it by default — pass your extra namespaces and you’re done:

resources/js/app.tsx
import { createLatticeApp } from "@lattice-php/lattice";
createLatticeApp({
// …registry, sprite, pages…
i18n: { namespaces: ["lattice", "app"] },
});

When the backend reports i18n as enabled, the first render waits for the translation setup (no flash of untranslated fallbacks), i18next’s HTTP backend loads translations from laravel-i18next’s routes, LocaleReload re-fetches the page after setLocale() dispatches lattice:locale-change, and every Inertia visit carries the Accept-Language header (Lattice’s own form, action, table, and fragment requests send it internally). When i18n is disabled — or the shared prop is absent — the inline English stands and the i18next chunk is never loaded. Pass i18n: false to opt out entirely.

Wiring createInertiaApp by hand instead? The building blocks are exported: run configureI18nFromPageProps(props.initialPage.props, { namespaces }) (from @lattice-php/ui/i18n) before the first render, mount <LocaleReload /> next to <App />, and set defaults: { visitOptions: withVisitHeaders }.

Lattice ships English and German and registers them automatically. To override a string or add a new locale, publish the bundled files into your app and edit them:

Terminal window
php artisan vendor:publish --tag=lattice-translations

This copies Lattice’s groups to lang/vendor/lattice/{locale}/, where Laravel resolves them ahead of the package’s own. Each domain is its own file — form.php, table.php, notifications.php, common.php — with the nested keys Lattice uses:

lang/vendor/lattice/de/form.php
return [
'editor' => [
'bold' => 'Fett',
'heading-1' => 'Überschrift 1',
],
// …
];
// lang/vendor/lattice/de/table.php
return [
'pagination' => [
'next' => 'Weiter',
'showing' => 'Zeige :from-:to von :total',
],
// …
];

Interpolated values use Laravel’s :placeholder syntax; laravel-i18next serves them as i18next {{placeholder}} tokens.

The same instance is available to your custom fields and columns. Inside a component, use the useT hook with your own namespace:

import { useT } from "@lattice-php/ui/i18n";
function SaveButton() {
const { t, locale, locales, setLocale } = useT("app"); // served from lang/{locale}/app.php
return <button>{t("save", "Save")}</button>;
}

Passing "lattice" reads Lattice’s namespace instead — the built-in chrome does exactly that. Outside of a component — building a label in a helper, say — use translate:

import { translate } from "@lattice-php/ui/i18n";
translate("app", "save", "Save");

Both take the English fallback inline, so they are safe to call before any translations have loaded.

Some strings can’t be translated at the point they’re produced — a queued listener flashing a callout, a realtime toast broadcast to many subscribers, or a notification stored today and read tomorrow by someone in a different locale. rt() builds a Translatable: an i18next key plus replacements, resolved through t() on the client, in the reader’s locale, at render time — instead of a finished string baked into the sender’s locale:

rt('billing:subscription-ends')->with(['plan' => 'Pro']);

->with() accepts scalars and DateTimeInterface instances. A date is serialized to an ISO 8601 string on the wire — the wire stays flat scalars, never a typed date marker — and formatted client-side, in the reader’s locale, by Lattice’s datetime formatter (a superset of i18next’s built-in one), using its normal format syntax in the translation string:

use Carbon\CarbonImmutable;
rt('billing:subscription-ends')->with([
'plan' => 'Pro',
'date' => CarbonImmutable::parse($subscription->ends_at),
]);
lang/en/billing.php
return ['subscription-ends' => 'Your {{plan}} plan ends on {{date, datetime(dateStyle: long)}}'];
// lang/de/billing.php
return ['subscription-ends' => 'Ihr {{plan}}-Plan endet am {{date, datetime(dateStyle: long)}}'];

Same stored payload, each reader gets their own date: an English reader sees “Your Pro plan ends on March 6, 2026”, a German reader sees “Ihr Pro-Plan endet am 6. März 2026”. The full Intl.DateTimeFormat option surface is available through the datetime(...) format — see i18next’s formatting docs for the option syntax. A replacement that isn’t a valid date (or isn’t a date at all) renders as its raw value instead of throwing.

rt() is used by callout and toast effects and notifications.

The frontend picks its initial language from localStorage.locale, the locale cookie, the <html lang="…"> attribute, then en. To switch at runtime, call setLocale():

import { setLocale } from "@lattice-php/ui/i18n";
setLocale("de");

This writes localStorage.locale, writes the locale cookie, updates <html lang>, changes the i18next language, and dispatches lattice:locale-change. The backend locale middleware reads a HasLocalePreference user’s preferred locale first, then the cookie, then the session locale, then the request’s Accept-Language header.

For server-driven screens, make locale choices normal actions that return a locale-change effect:

use Lattice\Actions\ActionDefinition;
use Lattice\Actions\ActionResult;
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsAction;
#[AsAction('app.locale.set')]
class SetLocaleAction extends ActionDefinition
{
public function definition(Action $action): Action
{
return $action->label(__('language.switch'));
}
public function handle(): ActionResult
{
$locale = $this->context('locale');
$locales = config('lattice.i18n.locales', []);
return is_string($locale) && is_array($locales) && in_array($locale, $locales, true)
? ActionResult::success()->localeChange($locale)
: ActionResult::success();
}
}

Then compose the choices wherever your layout accepts components:

use Lattice\Actions\Components\Action;
use Lattice\Actions\Components\ActionGroup;
use Lattice\Ui\Components\FloatingPanel;
use Lattice\Ui\Enums\Emphasis;
use Lattice\Ui\Enums\FloatingPlacement;
use Lattice\Ui\Enums\Orientation;
$locale = app()->getLocale();
FloatingPanel::make('locale-switcher-panel')
->label(__('language.label'))
->placement(FloatingPlacement::TopEnd)
->schema([
ActionGroup::make('locale-switcher')
->label(__('language.label'))
->inline(Orientation::Horizontal)
->actions([
Action::use(SetLocaleAction::class)
->key('locale-en')
->label(__('language.en'))
->emphasis($locale === 'en' ? Emphasis::Solid : Emphasis::Ghost)
->context(['locale' => 'en']),
Action::use(SetLocaleAction::class)
->key('locale-de')
->label(__('language.de'))
->emphasis($locale === 'de' ? Emphasis::Solid : Emphasis::Ghost)
->context(['locale' => 'de']),
]),
]);

For custom React surfaces, Lattice provides headless switcher helpers so your app controls the markup and styling:

import { LocaleSwitcher } from "@lattice-php/ui/i18n";
function LanguageSwitcher() {
return (
<LocaleSwitcher namespace="app">
{({ options, setLocale }) => (
<div>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.active}
onClick={() => setLocale(option.value)}
>
{option.label}
</button>
))}
</div>
)}
</LocaleSwitcher>
);
}

The labels resolve from language.{locale} in the given namespace, falling back to the locale code. Use useLocaleOptions() directly when a hook fits your component better than a render prop.

configureI18nFromPageProps(props, options?)

Section titled “configureI18nFromPageProps(props, options?)”

Reads the shared lattice.i18n once prop from Inertia page props and calls configureI18n. Pass the namespaces your React components need:

void configureI18nFromPageProps(props.initialPage.props, {
namespaces: ["lattice", "app"],
});

Use as createInertiaApp({ defaults: { visitOptions: withVisitHeaders } }). It preserves the visit options and adds the active Accept-Language header.

Listens for lattice:locale-change and visits the current URL with { preserveScroll: true, preserveState: true }. Preserving state means the visit only swaps in the re-localized props without remounting the page, so table sort/filter, form input, scroll, and focus survive the switch. Override either option if your app needs a different reload behavior.

Returns { locale, locales, options, setLocale }, where options is a list of { value, label, active } objects built from the backend-supported locales.

configureI18n calls this for you, but you can call it directly for full control over the backend. It registers i18next’s HTTP backend and is the opt-in import.

Option Default Purpose
loadPath /locales/{{lng}}/{{ns}}.json Route translations are fetched from.
addPath /locales/add/{{lng}}/{{ns}} Route missing keys are reported to.
saveMissing false Report keys with no translation back to the backend, which persists them.
customHeaders Returns extra request headers, e.g. a CSRF token for the saveMissing POST.

The defaults match laravel-i18next’s namespaced routes; override them only behind a custom route prefix.

The block Lattice shares as an Inertia once prop:

type I18nConfig = {
enabled: boolean; // are the translation routes serving?
saveMissing: boolean; // should missing keys be reported back?
locales: string[]; // supported locale codes from config('lattice.i18n.locales')
preloadLocales: string[]; // locales eagerly loaded at startup, from config('lattice.i18n.preload_locales')
timezone: string | null; // the user's preferredTimezone(), when the user model implements HasTimezonePreference
};

configureI18n(config) enables the backend only when enabled is true, forwards saveMissing, stores locales for useT, useLocaleOptions, and LocaleSwitcher, and preloads preloadLocales in the background. The load and add paths are fixed on the frontend, so they never travel in this prop.