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