Files
core/docs/localization.md
T

3.8 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\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 by Modules\Core\Localization\LanguageCacheObserver, which observes Lunar\Models\Language saved/deleted events. Adding, editing, or removing a language via the Filament Languages resource clears the cache immediately — no TTL, no stale reads.


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


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.