feat(i18n): database-backed translation loader with Redis cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 01:20:48 +02:00
parent 15f4b2c0d8
commit ce9e53d206
5 changed files with 122 additions and 1 deletions

View File

@ -0,0 +1,10 @@
<?php
namespace App\Domains\Translation\Models;
use Illuminate\Database\Eloquent\Model;
class Translation extends Model
{
protected $guarded = ['id'];
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Domains\Translation\Providers;
use App\Domains\Translation\Models\Translation;
use Illuminate\Contracts\Translation\Loader;
use Illuminate\Support\Facades\Redis;
class DatabaseTranslationLoader implements Loader
{
public function load($locale, $group, $namespace = null): array
{
$cacheKey = "lang:{$locale}:{$group}";
$cached = Redis::hGetAll($cacheKey);
if (! empty($cached)) {
return $cached;
}
$translations = Translation::where('locale', $locale)
->where('group', $group)
->pluck('value', 'key')
->toArray();
if (! empty($translations)) {
Redis::hMSet($cacheKey, $translations);
Redis::expire($cacheKey, 3600);
}
return $translations;
}
public function addNamespace($namespace, $hint): void {}
public function addPath($path): void {}
public function addJsonPath($path): void {}
public function namespaces(): array
{
return [];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Domains\Translation\Services;
use App\Domains\Translation\Models\Translation;
use Illuminate\Support\Facades\Redis;
class TranslationService
{
public function set(string $locale, string $group, string $key, string $value): Translation
{
$translation = Translation::updateOrCreate(
['locale' => $locale, 'group' => $group, 'key' => $key],
['value' => $value, 'namespace' => '*'],
);
Redis::del("lang:{$locale}:{$group}");
return $translation;
}
public function flushCache(string $locale, string $group): void
{
Redis::del("lang:{$locale}:{$group}");
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Domains\Translation\Providers\DatabaseTranslationLoader;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Policies\WorkspacePolicy;
use Illuminate\Support\Facades\Gate;
@ -14,7 +15,21 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton('translation.loader', function ($app) {
return new DatabaseTranslationLoader;
});
// TranslationServiceProvider is deferred and re-binds translation.loader
// when it resolves. We extend 'translator' to swap in our DB loader after
// the translator instance is created.
$this->app->extend('translator', function ($translator, $app) {
$loader = new DatabaseTranslationLoader;
$reflection = new \ReflectionProperty($translator, 'loader');
$reflection->setAccessible(true);
$reflection->setValue($translator, $loader);
return $translator;
});
}
/**

View File

@ -0,0 +1,29 @@
<?php
use App\Domains\Translation\Models\Translation;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Redis;
beforeEach(function () {
Redis::del('lang:de:auth');
});
afterEach(function () {
Redis::del('lang:de:auth');
});
it('loads translation from database via __()', function () {
Translation::create([
'locale' => 'de',
'namespace' => '*',
'group' => 'auth',
'key' => 'login',
'value' => 'Anmelden',
]);
App::setLocale('de');
// Force reload
app('translator')->setLoaded([]);
expect(__('auth.login'))->toBe('Anmelden');
});