Produced by FOURIER

Laravelで多言語モデルを実装

HirayamaHirayama calender 2022.3.7

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

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

*Youtubeの字幕設定で言語選択した際の画面*

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

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

作成するシステムの要件

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

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

環境構築

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

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

Installation - Laravel - The PHP Framework For Web Artisans

Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation — freeing you to create without sweating the small things.

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

上記コマンドでエラーが発生せず、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"
},

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テーブル

カラム名属性意味
idprimaryBIG INTEGER
created_atTIMESTAMP
updated_atTIMESTAMP
deleted_atnullableTIMESTAMP

TagTranslationsテーブル

カラム名属性意味
tag_idprimaryBIG INTEGERTagsテーブルのid
localeprimarychar(5)言語コード
nameVARCHAR(255)タグ名

Languagesテーブルを作成しない理由

このシステムでは対応する言語情報を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 / Engineer

1997年生まれ、南伊豆出身。学生時代にC#で画像処理アプリケーションを作ったりしていました。業務では主にLaravelを使用してサーバーサイドのプログラミングをしています。趣味はドライブとシミュレーションゲーム。