2026-08-05 23:58:53 +03:00
# 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:
```php
// 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`
2026-08-06 12:06:55 +03:00
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\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* .
2026-08-05 23:58:53 +03:00
---
## Reading the resolved locale/language downstream
```php
// 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', ...)` ).
2026-08-26 13:26:29 +03:00
### 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:
```blade
{{-- 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.
2026-08-05 23:58:53 +03:00
---
## 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.
2026-08-06 00:13:13 +03:00
---
## 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` ](https://github.com/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
```blade
{{ __('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.
2026-08-06 12:06:55 +03:00
### 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\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` ):
2026-08-06 00:13:13 +03:00
```php
2026-08-06 12:06:55 +03:00
use Modules\Core\Localization\TranslationService ;
2026-08-06 00:13:13 +03:00
2026-08-06 12:06:55 +03:00
app ( TranslationService :: class ) -> create ( 'storefront' , 'nav.wishlist' , [
'en' => 'Wishlist' ,
'el' => 'Λίστα Επιθυμιών' ,
2026-08-06 00:13:13 +03:00
]);
2026-08-06 12:06:55 +03:00
app ( TranslationService :: class ) -> update (
$languageLine ,
'storefront' ,
'nav.wishlist' ,
[ 'en' => 'Wishlist ♥' , 'el' => 'Λίστα Επιθυμιών ♥' ],
);
app ( TranslationService :: class ) -> delete ( $languageLine );
2026-08-06 00:13:13 +03:00
```
2026-08-06 12:06:55 +03:00
`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.