6.6 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*, seePanelServiceProvider) 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:
- Reads the first path segment (
request()->segment(1)). - Matches it against
Lunar\Models\Language::code.- Match —
App::setLocale($code)is set, andlocale/languagerequest 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-Languageheader against available language codes, or - the language flagged
defaultin thelanguagestable, or - the first language row, as a last resort.
- the best match from the
- Match —
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.
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.
Seeding
A starter set of common e-shop labels (nav.*, cart.*, product.*, auth.*, search.*,
English + Greek) is seeded by Modules\Core\Command\InstallLunarCommand (overrides Lunar's own
lunar:install), guarded by LanguageLine::where('group', 'storefront')->exists() — same
idempotent pattern as the rest of that command, safe to run unattended on every boot.
Editing existing labels or adding new ones is a normal Eloquent operation, not a re-seed:
use Spatie\TranslationLoader\LanguageLine;
LanguageLine::create([
'group' => 'storefront',
'key' => 'nav.wishlist',
'text' => ['en' => 'Wishlist', 'el' => 'Λίστα Επιθυμιών'],
]);
There is currently no Filament resource for editing language_lines — labels are edited via
tinker/seeder until one is built. LanguageLine caches per group+locale
(Cache::rememberForever) and flushes itself automatically on saved/deleted, so edits take
effect immediately with no manual cache-clear step.