Files
core/docs/localization.md

14 KiB

Localization

Storefront routes can be locale-prefixed (/el/proionta, /en/products) using a middleware that reads directly from Lunar's languages table — the same table the Filament Languages resource manages, so there's no separate locale config to keep in sync.


Why prefix every locale, including the default

Leaving the default locale bare at the root (/proionta for Greek, /en/products for English) creates ambiguity: is / the language-neutral homepage or specifically the Greek version? It also complicates hreflang (needs a self-referencing tag on the root plus a possibly-duplicate x-default) and risks duplicate content if a bot or campaign link reaches the root without a language signal.

Prefixing every locale avoids this: every URL unambiguously declares its language, hreflang tags are symmetrical, and adding a locale later requires no URL restructuring.


Opt-in, not global

The middleware is registered as a named alias (locale), not pushed onto the web middleware group. Apply it explicitly to the route group(s) that make up your storefront:

// routes/web.php
use Illuminate\Support\Facades\Route;

Route::middleware('locale')->group(function () {
    Route::get('/{locale}', HomeController::class);
    Route::get('/{locale}/proionta', ProductIndexController::class);
    Route::get('/{locale}/proionta/{slug}', ProductShowController::class);
});

It is not applied automatically because storefront routes aren't the only routes living under web in a shop:

  • The Filament admin panel (/boboko*, see PanelServiceProvider) has its own routing/auth concerns and must never be locale-redirected.
  • Livewire's internal update endpoint (/livewire/update) must resolve without a locale prefix.
  • Webhooks, health checks, and other non-storefront routes shouldn't be touched.

If a shop's entire web.php is the storefront, wrapping the whole file in the group above is fine — just keep admin/Livewire/webhook routes registered outside of it (as they already are).


Behavior

Modules\Core\Localization\Middleware\LocaleMiddleware:

  1. Reads the first path segment (request()->segment(1)).
  2. Matches it against Lunar\Models\Language::code.
    • Match — App::setLocale($code) is set, and locale / language request attributes are populated for controllers/views to use.
    • No match (missing, wrong, or unknown segment) — redirects to the same path prefixed with a resolved locale:
      • the best match from the Accept-Language header against available language codes, or
      • the language flagged default in the languages table, or
      • the first language row, as a last resort.

The language list is cached with Cache::rememberForever() under core.localization.languages and invalidated automatically. Adding, editing, or removing a language via the Filament Languages resource clears the cache immediately — no TTL, no stale reads.

How invalidation is wired (event-driven, not the observer itself)

Modules\Core\Localization\Observers\LanguageCacheObserver observes Lunar\Models\Language's created/updated/deleted Eloquent events, but it's a thin trigger only — it doesn't do any invalidation work itself. It dispatches one of three events from Modules\Core\Localization\Events (LanguageCreated, LanguageUpdated — carrying the old code — or LanguageDeleted), and two listeners, wired in Modules\Core\Providers\LocalizationServiceProvider, react:

  • FlushLanguageCache — flushes core.localization.languages on all three events.
  • MigrateTranslationsForRenamedLanguage — LanguageUpdated only, and only when code actually changed. A renamed Language::code (e.g. el → gr) would otherwise strand every LanguageLine's translated text under the old, now-unroutable key — getTranslationsForGroup('gr', ...) would silently return nothing for that locale even though the translated content still exists. This listener moves the text.{oldCode} key to text.{newCode} on every affected LanguageLine row and flushes both the old and new code's translation cache for every group touched.

Deleting a Language only flushes the language-list cache — LanguageLine.text keys for the deleted code are left in place rather than destructively erased, in case the language is ever re-added under the same code.

Splitting cache-flush and text-migration into separate listeners (rather than one LanguageCacheObserver method doing both) mirrors the same event → listener pattern used for TranslationService's writes below — the observer only detects what happened, listeners own what to do about it.


Reading the resolved locale/language downstream

// In a controller or view composer
$locale = $request->attributes->get('locale');      // e.g. "el"
$language = $request->attributes->get('language');   // Lunar\Models\Language instance

Use $language->id when querying Lunar's translatable content (e.g. Url::where('language_id', ...)).

Shared view data — language switcher and hreflang tags

The middleware also shares two variables with every view, via View::share(), so a layout's language switcher or hreflang tags don't have to recompute the language list themselves:

{{-- current locale --}}
{{ $currentLocale }}   {{-- e.g. "el" --}}

{{-- every OTHER configured language, each with its own URL for the current page --}}
@foreach ($altLocales as $altLocale)
    <a href="{{ $altLocale['url'] }}" hreflang="{{ $altLocale['code'] }}">{{ $altLocale['name'] }}</a>
@endforeach

$altLocales is a collection, not a single value — deliberately, so it scales to any number of configured languages rather than assuming exactly two. Each entry is a plain array:

Key Description
code The language's Lunar\Models\Language::code (e.g. en)
name The language's display name
url The current route, re-generated with that language's code — via route($routeName, [...]) when the current request matched a named route, or a bare /{code} fallback otherwise

A 3+ language store gets one $altLocales entry per additional language automatically — nothing about this shape assumes or special-cases a two-language store.


Single-language shops

If a shop has only one row in languages, the middleware still enforces the prefix (e.g. every URL under /en/...) rather than special-casing it away — this keeps behavior identical across shops and avoids a silent restructuring if a second language is added later. If a shop genuinely never wants locale prefixes, don't apply the locale middleware to its routes at all.


Storefront UI labels (__('storefront.*'))

The locale middleware resolves which language a request is in — routing/redirects, Lunar\Models\Language, and Lunar's own translatable product/collection content. It has nothing to do with static UI chrome like "Cart", "Back", "Add to Cart". Those are handled separately by spatie/laravel-translation-loader, stored in the language_lines table.

Why a separate system, not another languages-table lookup

Lunar's translatable fields (TranslatedText, Url, etc.) are all tied to specific model records — a product's name, a collection's description. UI labels aren't attached to any model; they're static strings the app itself owns. laravel-translation-loader is Laravel's own __()/trans() mechanism with a DB-backed source layered on top of the normal file-based one — no new helper to learn, no bespoke table shape.

Nothing existing breaks. The package's TranslationLoaderManager extends Laravel's FileLoader and merges DB translations on top of file-based ones (array_replace_recursive()) — Filament's own vendor lang/en/product.php-style strings keep working exactly as before. The package registers itself via Laravel's standard Composer package auto-discovery (extra.laravel.providers in its own composer.json) — nothing needed in CoreServiceProvider to wire it up.

Usage

{{ __('storefront.nav.cart') }}
{{ __('storefront.product.add_to_cart') }}

group is storefront for e-shop UI labels — kept separate from Lunar/Filament's own lunar:: namespaced groups so nothing collides. __() resolves the translation for whatever App::getLocale() currently is, which LocaleMiddleware already sets per-request (see "Behavior" above) — no extra wiring needed between the two systems.

Fallback locale follows the store's default language, not config('app.fallback_locale')

spatie/laravel-translation-loader's stock LanguageLine::getTranslation() falls back to config('app.fallback_locale') — a static .env value — when a key has no text for the current locale. That's a second, disconnected "default language" concept: an admin changing the default language via the Filament Languages resource has no effect on it, so an untranslated label could silently fall back to the wrong language.

Modules\Core\Localization\Models\LanguageLine overrides getTranslation() to fall back to LanguageCache::defaultLocale() instead — the same languages.default flag LocaleMiddleware already treats as the single source of truth. It's swapped in via config('translation-loader.model') (the package's own documented extension point for "any model that extends LanguageLine"), set in LocalizationServiceProvider::register() so it wins regardless of provider boot order (Laravel's mergeConfigFrom() only fills in config keys not already set, so an explicit register()-time set always beats the package's own default). No consuming app configuration needed — this is automatic once LocalizationServiceProvider is registered.

Seeding

A starter set of common e-shop labels (nav.*, cart.*, product.*, auth.*, search.*, review.*, shop.*, pagination.*, English + Greek) lives in Modules\Core\Localization\Services\StorefrontLabels::all() — kept as its own class, separate from the seeding logic, so the label list can be scanned/diffed without wading through the seeding mechanics.

Modules\Core\Command\InstallLunarCommand (overrides Lunar's own lunar:install) seeds them via a per-key upsert, not an all-or-nothing "only seed if the group is empty" guard: a key already present in the database — including one an admin has since edited via the Filament Language Lines resource — is left untouched; only keys missing entirely are created. This is what makes it safe to add new keys to StorefrontLabels::all() later and re-run lunar:install on an already-installed store, without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default (what a naive updateOrCreate would do). New writes go through TranslationService::create(), so the usual cache-invalidation and activity-log events fire for them too.

Admin UI

Modules\Core\Localization\Filament\Resources\LanguageLineResource (registered in CorePlugin, under the panel's Settings group) lists/searches/filters language_lines and edits each row's group, key, and one text input per row currently in lunar_languages — the locale columns are generated dynamically from Language::query()->pluck('code'), so adding a third language automatically adds a third input, no resource changes needed.

TranslationService — writes go through here, not the model directly

Modules\Core\Localization\Services\TranslationService wraps create/update/delete on LanguageLine and dispatches a domain event after each write, following this project's standard event-driven pattern (see modules.md's "Splitting Service Providers" / event-listener convention — the same shape as Modules\Core\Auth\Events\UserCreated):

use Modules\Core\Localization\Services\TranslationService;

app(TranslationService::class)->create('storefront', 'nav.wishlist', [
    'en' => 'Wishlist',
    'el' => 'Λίστα Επιθυμιών',
]);

app(TranslationService::class)->update(
    $languageLine,
    'storefront',
    'nav.wishlist',
    ['en' => 'Wishlist ♥', 'el' => 'Λίστα Επιθυμιών ♥'],
);

app(TranslationService::class)->delete($languageLine);

update() takes the full group/key/text state, not just text — a rename is a normal update, not a special case. TranslationCreated, TranslationUpdated (carries the full {group, key, text} snapshot from before the update, so a listener can tell a rename from a text edit), and TranslationDeleted are dispatched from Modules\Core\Localization\Events. Two listeners are wired in Modules\Core\Providers\LocalizationServiceProvider for all three events:

  • FlushTranslationCache — LanguageLine::boot() already flushes the cache for the current group's locales present after a save, but misses two cases on update: locales a save removed from text (e.g. dropping the el key leaves storefront.el stale), and a changed group/key (the old group's cached array is never told a row left it). This listener flushes every group+locale combination touched by either the old or new state, so nothing — including the group a row was renamed away from — can remain stale.
  • LogTranslationActivity — records the change via Modules\Core\Logging\ActivityLogService on the lunar activity log channel, same created/updated/deleted shape as every other domain write in this project. A rename shows up in the log as an old/attributes diff across group, key, and text together, not just a text diff.

The Filament resource's Create/Edit/Delete pages route through TranslationService (via handleRecordCreation/handleRecordUpdate/the delete action's ->action() override) rather than Filament's default direct-model calls, so every edit made in the admin UI — including a bare group/key rename with no text change — dispatches TranslationUpdated and is both cache-invalidated and audit-logged.