diff --git a/composer.json b/composer.json index 84768af..0a36ed3 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "symfony/yaml": "^7.0", "lunarphp/table-rate-shipping": "^1.3", "lunarphp/search": "*", - "lunarphp/meilisearch": "*" + "lunarphp/meilisearch": "*", + "spatie/laravel-translation-loader": "^2.8" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -33,13 +34,15 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\LocalizationServiceProvider" ] } }, "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "php-http/discovery": true } } } diff --git a/database/migrations/2026_08_05_210535_create_language_lines_table.php b/database/migrations/2026_08_05_210535_create_language_lines_table.php new file mode 100644 index 0000000..d24d12a --- /dev/null +++ b/database/migrations/2026_08_05_210535_create_language_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('group')->index(); + $table->string('key'); + $table->json('text'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('language_lines'); + } +}; diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..18fe74d --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,215 @@ +# 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. 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*. + +--- + +## 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. + +--- + +## 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. + +### 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`): + +```php +use Modules\Core\Localization\TranslationService; + +app(TranslationService::class)->create('storefront', 'nav.wishlist', [ + 'en' => 'Wishlist', + 'el' => 'Λίστα Επιθυμιών', +]); + +app(TranslationService::class)->update( + $languageLine, + 'storefront', + 'nav.wishlist', + ['en' => 'Wishlist ♥', 'el' => 'Λίστα Επιθυμιών ♥'], +); + +app(TranslationService::class)->delete($languageLine); +``` + +`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. diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index f74b925..e07d3a3 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -18,6 +18,7 @@ use Lunar\Models\Product; use Lunar\Models\ProductType; use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; +use Spatie\TranslationLoader\LanguageLine; /** * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate @@ -241,9 +242,43 @@ class InstallLunarCommand extends Command } }); + if (! LanguageLine::where('group', 'storefront')->exists()) { + $this->components->info('Seeding storefront label translations'); + $this->seedStorefrontLabels(); + } + $this->components->info('Publishing Filament assets'); $this->call('filament:assets'); $this->components->info('Lunar default data seeded.'); } + + private function seedStorefrontLabels(): void + { + $labels = [ + 'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'], + 'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'], + 'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'], + 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], + 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], + 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], + 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], + 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], + 'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'], + 'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'], + 'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'], + 'product.price' => ['en' => 'Price', 'el' => 'Τιμή'], + 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], + 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], + 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], + ]; + + foreach ($labels as $key => $text) { + LanguageLine::create([ + 'group' => 'storefront', + 'key' => $key, + 'text' => $text, + ]); + } + } } diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 38a356f..ef0bd4a 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -15,6 +15,7 @@ use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; +use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -32,6 +33,9 @@ class CorePlugin implements Plugin ->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->login(Login::class) + ->resources([ + LanguageLineResource::class, + ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ diff --git a/src/Localization/Events/LanguageCreated.php b/src/Localization/Events/LanguageCreated.php new file mode 100644 index 0000000..d9aabf0 --- /dev/null +++ b/src/Localization/Events/LanguageCreated.php @@ -0,0 +1,12 @@ +schema([ + Forms\Components\TextInput::make('group') + ->required() + ->maxLength(255) + ->default('storefront') + ->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'), + + Forms\Components\TextInput::make('key') + ->required() + ->maxLength(255) + ->helperText('Dot-notation key, e.g. "nav.cart".'), + + Forms\Components\Fieldset::make('Translations') + ->schema(static::localeInputs()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('group') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('key') + ->searchable() + ->sortable(), + ...static::localeColumns(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('group') + ->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')), + ]) + ->defaultSort('key'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLanguageLines::route('/'), + 'create' => Pages\CreateLanguageLine::route('/create'), + 'edit' => Pages\EditLanguageLine::route('/{record}/edit'), + ]; + } + + /** + * @return array + */ + private static function localeInputs(): array + { + return static::localeCodes() + ->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}") + ->label(strtoupper($code)) + ->rows(2)) + ->all(); + } + + /** + * @return array + */ + private static function localeColumns(): array + { + return static::localeCodes() + ->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}") + ->label(strtoupper($code)) + ->limit(40) + ->toggleable()) + ->all(); + } + + private static function localeCodes(): \Illuminate\Support\Collection + { + return Language::query()->pluck('code'); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php new file mode 100644 index 0000000..7870fe2 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php @@ -0,0 +1,22 @@ +create( + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php new file mode 100644 index 0000000..80c864a --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php @@ -0,0 +1,53 @@ +action(function (LanguageLine $record) { + app(TranslationService::class)->delete($record); + + $this->redirect($this->getResource()::getUrl('index')); + }), + ]; + } + + /** + * Filament's default Cancel button uses window.history.back(), which + * restores the browser's cached previous page instead of re-fetching — + * so an edit made just before clicking Cancel doesn't show up in the + * list until a manual refresh. Redirect through Livewire instead, which + * always re-queries. + */ + protected function getCancelFormAction(): Action + { + return Action::make('cancel') + ->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label')) + ->url(static::getResource()::getUrl('index')) + ->color('gray'); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + return app(TranslationService::class)->update( + $record, + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php new file mode 100644 index 0000000..12c7b37 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php @@ -0,0 +1,19 @@ + $language->getOriginal('code'), + ])); + } + + public function deleted(Language $language): void + { + Event::dispatch(new LanguageDeleted($language)); + } +} diff --git a/src/Localization/Listeners/FlushLanguageCache.php b/src/Localization/Listeners/FlushLanguageCache.php new file mode 100644 index 0000000..575811c --- /dev/null +++ b/src/Localization/Listeners/FlushLanguageCache.php @@ -0,0 +1,16 @@ +flush($event->languageLine->group, array_keys($event->languageLine->text ?? [])); + + if ($event instanceof TranslationUpdated) { + $this->flush($event->old['group'], array_keys($event->old['text'] ?? [])); + } + } + + private function flush(string $group, array $locales): void + { + foreach ($locales as $locale) { + Cache::forget(LanguageLine::getCacheKey($group, $locale)); + } + } +} diff --git a/src/Localization/Listeners/LogTranslationActivity.php b/src/Localization/Listeners/LogTranslationActivity.php new file mode 100644 index 0000000..3a83b7b --- /dev/null +++ b/src/Localization/Listeners/LogTranslationActivity.php @@ -0,0 +1,53 @@ +languageLine; + + match (true) { + $event instanceof TranslationCreated => $this->activityLog->created( + $languageLine, + $this->flatten($languageLine), + ), + $event instanceof TranslationUpdated => $this->activityLog->updated( + $languageLine, + Arr::dot($event->old), + $this->flatten($languageLine), + ), + $event instanceof TranslationDeleted => $this->activityLog->deleted( + $languageLine, + $this->flatten($languageLine), + ), + }; + } + + /** + * Filament's Activity resource renders `properties` with a flat KeyValue + * field, which can't display a nested value like `text: {en, el}` — it + * shows as "[object Object]". Flatten to dot-notation ("text.en", + * "text.el") so every property is a plain string, viewable as-is. + */ + private function flatten(LanguageLine $languageLine): array + { + return Arr::dot([ + 'group' => $languageLine->group, + 'key' => $languageLine->key, + 'text' => $languageLine->text, + ]); + } +} diff --git a/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php new file mode 100644 index 0000000..046fd23 --- /dev/null +++ b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php @@ -0,0 +1,46 @@ + "gr") would otherwise strand every + * LanguageLine's translated text under the old, now-unroutable key — + * getTranslationsForGroup($newCode, ...) would silently return nothing for + * that locale even though the translated content still exists. Move the + * text.{oldCode} key to text.{newCode} on every affected row instead. + */ +class MigrateTranslationsForRenamedLanguage +{ + public function handle(LanguageUpdated $event): void + { + $oldCode = $event->old['code']; + $newCode = $event->language->code; + + if ($oldCode === $newCode) { + return; + } + + $affectedGroups = []; + + LanguageLine::query() + ->whereJsonContainsKey('text->'.$oldCode) + ->each(function (LanguageLine $languageLine) use ($oldCode, $newCode, &$affectedGroups) { + $text = $languageLine->text; + $text[$newCode] = $text[$oldCode]; + unset($text[$oldCode]); + + $languageLine->update(['text' => $text]); + + $affectedGroups[$languageLine->group] = true; + }); + + foreach (array_keys($affectedGroups) as $group) { + Cache::forget(LanguageLine::getCacheKey($group, $oldCode)); + Cache::forget(LanguageLine::getCacheKey($group, $newCode)); + } + } +} diff --git a/src/Localization/LocaleMiddleware.php b/src/Localization/LocaleMiddleware.php new file mode 100644 index 0000000..f3e4918 --- /dev/null +++ b/src/Localization/LocaleMiddleware.php @@ -0,0 +1,78 @@ +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/Localization/TranslationReader.php b/src/Localization/TranslationReader.php new file mode 100644 index 0000000..4f57c70 --- /dev/null +++ b/src/Localization/TranslationReader.php @@ -0,0 +1,22 @@ + 'Cart', 'nav.home' => 'Home']. + * Backed by LanguageLine's own forever-cache, so this is a cache hit after + * the first call for a given group+locale. + */ + public function group(string $group = self::DEFAULT_GROUP, ?string $locale = null): array + { + return LanguageLine::getTranslationsForGroup($locale ?? App::getLocale(), $group); + } +} diff --git a/src/Localization/TranslationService.php b/src/Localization/TranslationService.php new file mode 100644 index 0000000..b6bf271 --- /dev/null +++ b/src/Localization/TranslationService.php @@ -0,0 +1,57 @@ + $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationCreated($languageLine)); + + return $languageLine; + } + + public function update(LanguageLine $languageLine, string $group, string $key, array $text): LanguageLine + { + // Callers (e.g. Filament's EditRecord) may hand us a model instance + // already filled with the new form values in memory — refresh from the + // database first so $old reflects what's actually persisted, not what's + // about to be written. + $persisted = $languageLine->fresh(); + + $old = [ + 'group' => $persisted->group, + 'key' => $persisted->key, + 'text' => $persisted->text, + ]; + + $languageLine->update([ + 'group' => $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationUpdated($languageLine, $old)); + + return $languageLine; + } + + public function delete(LanguageLine $languageLine): void + { + $languageLine->delete(); + + Event::dispatch(new TranslationDeleted($languageLine)); + } +} diff --git a/src/Providers/LocalizationServiceProvider.php b/src/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..d2392eb --- /dev/null +++ b/src/Providers/LocalizationServiceProvider.php @@ -0,0 +1,39 @@ +app['router']->aliasMiddleware('locale', LocaleMiddleware::class); + Language::observe(LanguageCacheObserver::class); + + foreach ([TranslationCreated::class, TranslationUpdated::class, TranslationDeleted::class] as $event) { + Event::listen($event, FlushTranslationCache::class); + Event::listen($event, LogTranslationActivity::class); + } + + foreach ([LanguageCreated::class, LanguageUpdated::class, LanguageDeleted::class] as $event) { + Event::listen($event, FlushLanguageCache::class); + } + + Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class); + } +}