From 93469d033fee7e9d5114a94f4dda389d4ae16734 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 5 Aug 2026 23:58:53 +0300 Subject: [PATCH] Feature: Adding Locale Middleware --- docs/localization.md | 90 ++++++++++++++++++++++ src/Localization/LanguageCacheObserver.php | 18 +++++ src/Localization/LocaleMiddleware.php | 78 +++++++++++++++++++ src/Providers/CoreServiceProvider.php | 6 ++ 4 files changed, 192 insertions(+) create mode 100644 docs/localization.md create mode 100644 src/Localization/LanguageCacheObserver.php create mode 100644 src/Localization/LocaleMiddleware.php diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..ba3c80e --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,90 @@ +# 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` +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 + +```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. diff --git a/src/Localization/LanguageCacheObserver.php b/src/Localization/LanguageCacheObserver.php new file mode 100644 index 0000000..2a4ce25 --- /dev/null +++ b/src/Localization/LanguageCacheObserver.php @@ -0,0 +1,18 @@ +availableLanguages(); + + if ($languages->isEmpty()) { + return $next($request); + } + + $segment = (string) $request->segment(1); + $language = $languages->firstWhere('code', $segment); + + if (! $language) { + return $this->redirectToLocalizedUrl($request, $languages); + } + + App::setLocale($language->code); + $request->attributes->set('locale', $language->code); + $request->attributes->set('language', $language); + + return $next($request); + } + + public static function forgetLanguagesCache(): void + { + Cache::forget(self::CACHE_KEY); + } + + private function redirectToLocalizedUrl(Request $request, Collection $languages): Response + { + $locale = $this->negotiateLocale($request, $languages); + + $path = trim($request->getPathInfo(), '/'); + $target = '/'.$locale.($path !== '' ? '/'.$path : ''); + + $query = $request->getQueryString(); + if ($query) { + $target .= '?'.$query; + } + + return redirect($target); + } + + private function negotiateLocale(Request $request, Collection $languages): string + { + $preferred = $request->getPreferredLanguage($languages->pluck('code')->all()); + + if ($preferred) { + return $preferred; + } + + return $languages->firstWhere('default', true)?->code + ?? $languages->first()->code; + } + + private function availableLanguages(): Collection + { + return Cache::rememberForever( + self::CACHE_KEY, + fn () => Language::query()->get(['id', 'code', 'name', 'default']), + ); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 0574c40..a1d3303 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -4,12 +4,15 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Lunar\Models\Language; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; +use Modules\Core\Localization\LanguageCacheObserver; +use Modules\Core\Localization\LocaleMiddleware; class CoreServiceProvider extends ServiceProvider { @@ -24,6 +27,9 @@ class CoreServiceProvider extends ServiceProvider Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); + Language::observe(LanguageCacheObserver::class); + $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/scout.php' => config_path('scout.php'),