Produced by Fourier

Laravelで多言語モデルを実装

Hirayama Hirayama カレンダーアイコン 2022.03.07

グローバル展開を考えているWebサイトを構成する際、ユーザーが必要に応じて様々な地域の言語を登録できるようなシステムを構築すると、よりWebサイトの利便性を向上させることができます。

例えばYouTubeの字幕はその一例で、動画ごとに任意の言語の字幕情報を登録することができます。

このようなWebサイトのバックエンドをLaravelで構築する際、どのようにすればいいか、簡単なシステムを例にLaravelの機能を活用した扱いやすい多言語モデルの実装方法を解説していきます。

この記事は一度Laravelを使ってWebサイトを作成した人向けに解説しているため、Laravelの細かい説明などについては説明を省いています。

作成するシステムの要件

システム要件は以下の通りです。

  • 最初に対応する言語は英語、日本語。今後増える可能性あり。
  • 複数の言語を同時に表示しない。
  • 上記言語データを持つ「タグ」を扱えるようにする。
  • タグが持つデータはタグ名(name)のみ。

環境構築

本システムはLaravel Sailを使用して環境構築します。

公式サイトのドキュメントに各OSごとのインストール方法が書かれているので、それを参考に環境構築します。

https://laravel.com/docs/8.x/installation#your-first-laravel-project
curl -s "https://laravel.build/i18n-laravel" | bash
cd i18n-laravel
./vendor/bin/sail up
macの場合

上記コマンドでエラーが発生せず、Dockerコンテナが起動すればうまく構築できています。

http://localhost にアクセスしてLaravelの初期ページが確認できればOKです。

構築した環境は以下の通りとなります。

"require": {
    "php": "^7.3|^8.0",
    "fruitcake/laravel-cors": "^2.0",
    "guzzlehttp/guzzle": "^7.0.1",
    "laravel/framework": "^8.75",
    "laravel/sanctum": "^2.11",
    "laravel/tinker": "^2.5"
},
"require-dev": {
    "facade/ignition": "^2.5",
    "fakerphp/faker": "^1.9.1",
    "laravel/sail": "^1.0.1",
    "mockery/mockery": "^1.4.4",
    "nunomaduro/collision": "^5.10",
    "phpunit/phpunit": "^9.5.10"
},
composer.json

Config設定

既存設定修正

まずは言語設定に関するものを修正します。

config/app.php を開き、以下の項目を修正します。

'locale' => 'ja',          //デフォルトの言語
'fallback_locale' => 'ja', //ブラウザ指定の言語ファイルが無かった場合に表示する言語

言語ファイル追加

対応している言語を設定するため、 config フォルダ下に locales.php ファイルを追加します。

<?php

return [
    'en',
    'ja',
];

追加後は ./vendor/bin/sail artisan config:cache を実行してキャッシュファイルを更新します。

テーブル構成からモデル作成まで

構成

タグの実装は、メタデータを持つ Tags テーブル、各言語ごとのデータを持つ TagTranslations テーブルの2つに分割します。

💡
TagsテーブルとTagTranslationsにもたせるデータの違い この二つのテーブルは、抽象的に考えると、Tagsにはタグそのものを説明するメタデータを、TagTranslationsにはタグのデータ(タグ名)を格納しています。

Tagsテーブル

カラム名 属性 意味
id primary BIG INTEGER
created_at TIMESTAMP
updated_at TIMESTAMP
deleted_at nullable TIMESTAMP

TagTranslationsテーブル

カラム名 属性 意味
tag_id primary BIG INTEGER Tagsテーブルのid
locale primary char(5) 言語コード
name VARCHAR(255) タグ名

TagTranslationsテーブル

このシステムでは対応する言語情報をconfigファイルとして書きました。 Languagesテーブルを作成し、整合性制約をかけることでより厳密なデータ保存が可能ですが、以下の理由によりデメリットのほうが上回るため、configファイルとして作成しました。

  • ページ表示のたびにデータベースにアクセスする必要性が高い
  • configファイルはデフォルトでキャッシュされるので、簡単に高速アクセスできる
  • DBの整合性制約コストがあるため、大量のデータを扱うときに足枷になる
  • 頻繁に対応言語が変わることはない
  • 厳密さはシステム側のコードで十分担保可能

マイグレーション

テーブル構成を考えたら、その構成を実際に構築するマイグレーションファイルを記述していきます。

tags

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTagsTable extends Migration
{
    public function up(): void
    {
        Schema::create('tags', static function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tags');
    }
}

tag translations

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTagTranslationsTable extends Migration
{
    public function up(): void
    {
        Schema::create('tag_translations', static function (Blueprint $table) {
            $table->foreignId('tag_id')->constrained();
            $table->char('locale', 5);
            $table->string('name');

            $table->primary(['tag_id', 'locale']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tag_translations');
    }
}

マイグレーションファイルを作成したら、 ./vendor/bin/sail artisan migrate:fresh を実行し、テーブルを作成します。

モデル作成

ファイル作成

マイグレーションファイルが書けたら、次にモデルを作成します。

class Tag extends Model
{
    use HasFactory, SoftDeletes;

    public function tagTranslations(): HasMany
    {
        return $this->hasMany(TagTranslation::class);
    }
}
class TagTranslation extends Model
{
    use HasFactory;
    public const CREATED_AT = null;
    public const UPDATED_AT = null;

    protected $fillable = [
        'language',
        'name',
    ];

    protected $casts = [
        'language' => 'string',
        'name'     => 'string',
    ];

    public function tag(): BelongsTo
    {
        return $this->belongsTo(Tag::class);
    }

    public function language(): BelongsTo
    {
        return $this->belongsTo(Language::class);
    }
}

使いやすさを向上させる

上記ファイルでも問題ありませんが、実際に使用しようとすると扱いづらさを感じると思います。

例えば、日本語のタグの名前を取り出す際は以下のように記述する必要があります。

$tag->tagTranslations()->where('language', 'ja')->first()->name;

ただ名前を取り出すだけなのに、毎回where文を書くのは面倒です。

今回のケースでは、同時に複数の言語を取り出す可能性がないという前提条件を考え、以下のアクセッサーをTag.phpファイルに定義します。

public function getCurrentTranslationAttribute(): ?Model
{
    return $this->tagTranslations()->where('language', App::getLocale())->first();
}

public function getNameAttribute(): ?string
{
    return $this->currentTranslation->name ?? null;
}

これで、以下のように簡単に現在の言語のnameを取り出せます。

$tag->name;

他の言語も取り出せるようにする

上記やり方で現在の言語のnameを取り出しやすくなりました。

他の言語も簡単に取り出せるようにするため、Modelの __get メソッドをオーバーライドします。

public function __get($key)
{
    if (Language::all()->pluck('code')) {
        return $this->tagTranslations->where('language', $key)->first();
    }
    
    return parent::__get($key);
}

これで、任意の言語のnameを取り出しやすくなりました。

$tag->en->name;

システムを多言語化する

モデルが作成できたので、次にシステムを多言語対応します。

Laravelではユーザーが設定した言語情報を保持する仕組みがなく、リクエストしてきたクライアントブラウザの言語設定で動作するので、開発者自身で現在の言語を保持する必要があります。

言語設定をSessionに保存する

簡単な言語変更用のフォームを作成し、フォームで設定された内容をセッションに保存します。

まず、 routes/web.php に以下のルートを作成します。

Route::get('/change_language', ChangeLanguage::class)->name('change_language');

次にコントローラーを作成し、入力された言語設定をセッションに保存します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Session;

class ChangeLanguage extends Controller
{
    public function __invoke(Request $request): RedirectResponse
    {
        Session::put('language', $request->input('language', config('app.fallback_locale')));

        return Redirect::back();
    }
}

Middlewareを作成する

次に、セッションに保存された言語設定を反映させる処理を作成します。

言語設定は全ページで反映させる必要があるので、そのような機能はMiddlewareに書くのが最適です。

以下の例では、 Language.php というMiddlewareを作成しています。

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;

class Language
{
    /**
     * Handle an incoming request.
     *
     * @param Request $request
     * @param Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     *
     * @return Response|RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        App::setLocale(session('language', config('app.fallback_locale')));

        return $next($request);
    }
}

次に、このMiddlewareを Kernel.php に追加します。

webに追加することで、 web.php で設定されているすべてのルートに対して、このMiddlewareが動作します。

/**
     * The application's route middleware groups.
     *
     * @var array<string, array<int, class-string|string>>
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            \App\Http\Middleware\Language::class,
        ],

        'api' => [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

最後に、bladeでフォームを作成して完了です。

<form method="get" action="{{route('change_language')}}" id="change_language_form">
    <label for="language">現在の言語: </label>
    <select id="language" name="language" onchange="change_language_form.submit()">
        @foreach(\App\Models\Language::all()->pluck('code') as $language)
            <option value="{{$language}}" {{$language === app()->getLocale() ? 'selected' : ''}}>
                {{$language}}
            </option>
        @endforeach
    </select>
</form>

これで、 App::getLocale() で設定した言語を取り出せます。

Hirayama

Hirayama slash forward icon Engineer

業務では主にPHPやTypeScriptを使用したバックエンドアプリケーションやデスクトップアプリケーションの開発をしています。趣味は登山。

関連記事