Feature: Adding Locale Middleware
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization;
|
||||||
|
|
||||||
|
use Lunar\Models\Contracts\Language as LanguageContract;
|
||||||
|
|
||||||
|
class LanguageCacheObserver
|
||||||
|
{
|
||||||
|
public function saved(LanguageContract $language): void
|
||||||
|
{
|
||||||
|
LocaleMiddleware::forgetLanguagesCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(LanguageContract $language): void
|
||||||
|
{
|
||||||
|
LocaleMiddleware::forgetLanguagesCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Lunar\Models\Language;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LocaleMiddleware
|
||||||
|
{
|
||||||
|
private const CACHE_KEY = 'core.localization.languages';
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$languages = $this->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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ namespace Modules\Core\Providers;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Lunar\Models\Language;
|
||||||
use Modules\Core\Command\AnonymizeCommand;
|
use Modules\Core\Command\AnonymizeCommand;
|
||||||
use Modules\Core\Command\ExportCleanupCommand;
|
use Modules\Core\Command\ExportCleanupCommand;
|
||||||
use Modules\Core\Command\ExportCommand;
|
use Modules\Core\Command\ExportCommand;
|
||||||
use Modules\Core\Command\ImportCommand;
|
use Modules\Core\Command\ImportCommand;
|
||||||
use Modules\Core\Command\InstallLunarCommand;
|
use Modules\Core\Command\InstallLunarCommand;
|
||||||
use Modules\Core\Command\MigrateImportCommand;
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
|
use Modules\Core\Localization\LanguageCacheObserver;
|
||||||
|
use Modules\Core\Localization\LocaleMiddleware;
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -24,6 +27,9 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||||
|
|
||||||
|
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
|
||||||
|
Language::observe(LanguageCacheObserver::class);
|
||||||
|
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
__DIR__ . '/../../config/core.php' => config_path('core.php'),
|
||||||
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
|
||||||
|
|||||||
Reference in New Issue
Block a user