サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

Laravelの多言語化(i18n)実装ガイド:言語切替、日付と通貨、RTL、アクセシビリティ

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

Laravelで多言語サイトを作るときは、画面の文言だけを翻訳しても十分ではありません。URL、日付と通貨、入力エラー、検索向けの言語別URL、読み上げ、右から左へ書く言語のレイアウトまで、一つの設計として扱う必要があります。

国際化(i18n)は、複数の言語や地域に対応できるようアプリケーションの構造を整えることです。実際の翻訳や地域別の表示調整はローカライゼーション(l10n)と呼ばれます。本記事では、Laravelの翻訳機能を起点に、Webアクセシビリティの基本を組み込んだ実装手順を解説します。

最初に決める五つの項目

実装を始める前に、次の項目を決めておくと、ルート、翻訳辞書、画面部品、テストの判断がぶれにくくなります。

多言語化を始める前の設計項目
設計項目 決める内容 後回しにした場合の問題
対応ロケール ja、en、arなど、アプリ内で受け付ける値 未対応の値がURLや設定へ入り込む
言語別URL /ja/productsのようにURLへロケールを含めるか 共有、キャッシュ、検索向け設定が複雑になる
翻訳辞書 配列とJSONの使い分け、キーの命名規則 同じ文言が重複し、修正箇所を追いにくくなる
地域別表示 タイムゾーン、数値形式、通貨コードをどこから得るか 表示言語と商取引上の条件を混同する
品質確認 自動テスト、キーボード、読み上げ、RTLの確認範囲 公開直前に翻訳漏れや操作不能が見つかる

翻訳ファイルを用意する

現行のLaravelでは、翻訳文字列をアプリケーション直下のlangディレクトリで管理します。初期状態にlangがないプロジェクトでは、次のコマンドで標準の言語ファイルを公開できます。

php ​artisan ​lang:publish
lang/
├─ ​en/
│ ​ ​├─ ​messages.php
│ ​ ​└─ ​validation.php
├─ ​ja/
│ ​ ​├─ ​messages.php
│ ​ ​└─ ​validation.php
├─ ​en.json
└─ ​ja.json

配列翻訳とJSON翻訳

翻訳ファイルの使い分け
方式 呼び出し例 向いている運用
配列翻訳 __('messages.save') 機能単位でキーを管理し、文言の用途を追跡したい場合
JSON翻訳 __('Save') 原文をそのままキーにし、多数の画面文言を扱う場合

配列翻訳では、nav.account.settingsのように機能と用途が分かるキーを使います。キーを画面上の位置だけで命名すると、レイアウト変更後に意味が合わなくなるためです。JSON翻訳では原文の変更がキーの変更にもなるので、コピー編集の手順を先に決めます。

プレースホルダーと複数形

人名や件数を文字列へ埋め込むときは、語順を固定せず、名前付きのプレースホルダーを使います。

<?php
// ​lang/en/messages.php
return ​[
 ​ ​ ​ ​'welcome' ​=> ​'Welcome, ​:name!',
 ​ ​ ​ ​'cart_items' ​=> ​'{0} ​Your ​cart ​is ​empty|{1} ​You ​have ​one ​item|[2,*] ​You ​have ​:count ​items',
];

// ​lang/ja/messages.php
return ​[
 ​ ​ ​ ​'welcome' ​=> ​':nameさん、ようこそ。',
 ​ ​ ​ ​'cart_items' ​=> ​'{0} ​カートは空です|[1,*] ​商品が:count件あります',
];
<p>{{ ​__('messages.welcome', ​['name' ​=> ​$user->name]) ​}}</p>
<p>{{ ​trans_choice('messages.cart_items', ​$count, ​['count' ​=> ​$count]) ​}}</p>

trans_choiceは、辞書に定義した件数の範囲から表示文を選びます。日本語の文章が件数によって変わらない場合でも、ゼロ件だけ別の案内にするなど、画面の目的に合わせて定義できます。

URLからロケールを安全に決める

言語別URLを採用する場合は、URLのロケールを一つのリクエスト中の基準にします。ユーザー設定やAccept-Languageは、言語コードのない入口から言語別URLへ案内するときに使い、明示されたURLを上書きしません。

// ​routes/web.php
use ​App\Http\Middleware\SetLocaleFromUrl;

Route::prefix('{locale}')
 ​ ​ ​ ​->where(['locale' ​=> ​'ja|en|ar'])
 ​ ​ ​ ​->middleware(SetLocaleFromUrl::class)
 ​ ​ ​ ​->group(function ​() ​{
 ​ ​ ​ ​ ​ ​ ​ ​Route::get('/', ​fn ​() ​=> ​view('home'))->name('home');
 ​ ​ ​ ​ ​ ​ ​ ​Route::get('/products', ​[ProductController::class, ​'index'])
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​->name('products.index');
 ​ ​ ​ ​});
<?php

namespace ​App\Http\Middleware;

use ​Closure;
use ​Illuminate\Http\Request;
use ​Illuminate\Support\Facades\App;
use ​Symfony\Component\HttpFoundation\Response;

class ​SetLocaleFromUrl
{
 ​ ​ ​ ​public ​function ​handle(Request ​$request, ​Closure ​$next): ​Response
 ​ ​ ​ ​{
 ​ ​ ​ ​ ​ ​ ​ ​$supported ​= ​['ja', ​'en', ​'ar'];
 ​ ​ ​ ​ ​ ​ ​ ​$locale ​= ​(string) ​$request->route('locale');

 ​ ​ ​ ​ ​ ​ ​ ​abort_unless(in_array($locale, ​$supported, ​true), ​404);
 ​ ​ ​ ​ ​ ​ ​ ​App::setLocale($locale);

 ​ ​ ​ ​ ​ ​ ​ ​return ​$next($request);
 ​ ​ ​ ​}
}

未対応の値を既定言語へ黙って置き換えると、誤ったURLが有効に見えてしまいます。ルートの制約とミドルウェアの検証をそろえ、対応ロケールを設定ファイルへ集約します。

LaravelのロケールとHTMLの言語タグを分ける

Laravelの翻訳ディレクトリでは、地域差を含むロケールにen_GBのような表記を使います。一方、HTMLのlangではen-GBのような言語タグを使います。アプリ内の値をそのままHTMLへ出さず、ロケールごとの表示設定を持たせます。

// ​config/locales.php
return ​[
 ​ ​ ​ ​'ja' ​=> ​['html_lang' ​=> ​'ja', ​'direction' ​=> ​'ltr', ​'intl_locale' ​=> ​'ja_JP'],
 ​ ​ ​ ​'en' ​=> ​['html_lang' ​=> ​'en', ​'direction' ​=> ​'ltr', ​'intl_locale' ​=> ​'en'],
 ​ ​ ​ ​'en_GB' ​=> ​['html_lang' ​=> ​'en-GB', ​'direction' ​=> ​'ltr', ​'intl_locale' ​=> ​'en_GB'],
 ​ ​ ​ ​'ar' ​=> ​['html_lang' ​=> ​'ar', ​'direction' ​=> ​'rtl', ​'intl_locale' ​=> ​'ar'],
];
@php
 ​ ​ ​ ​$locale ​= ​app()->getLocale();
 ​ ​ ​ ​$settings ​= ​config('locales.'.$locale);
@endphp
<!doctype ​html>
<html ​lang='{{ ​$settings['html_lang'] ​}}' ​dir='{{ ​$settings['direction'] ​}}'>
<head>
 ​ ​ ​ ​<meta ​charset='utf-8'>
 ​ ​ ​ ​<title>@yield('title', ​__('messages.site_title'))</title>
</head>
<body>
 ​ ​ ​ ​<a ​href='#main'>{{ ​__('messages.skip_to_content') ​}}</a>
 ​ ​ ​ ​<main ​id='main'>@yield('content')</main>
</body>
</html>

langは発音規則や文字処理の判断材料になり、dirは文字の基準方向を決めます。同じ役割ではないため、RTL言語では両方を設定します。文章の一部だけ別言語になる場合は、その要素にも適切なlangを付けます。

言語切替UIで現在地を伝える

言語名は国旗や言語コードだけで表さず、日本語、English、العربيةのように各言語自身の表記を使います。現在の言語には、見た目の強調に加えてaria-current='page'を付けます。

<nav ​aria-label='{{ ​__('messages.language_switcher') ​}}'>
 ​ ​ ​ ​<ul>
 ​ ​ ​ ​ ​ ​ ​ ​@foreach ​($languageLinks ​as ​$item)
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<li>
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<a ​href='{{ ​$item['url'] ​}}'
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​lang='{{ ​$item['html_lang'] ​}}'
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​hreflang='{{ ​$item['html_lang'] ​}}'
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​@if ​($item['locale'] ​=== ​app()->getLocale()) ​aria-current='page' ​@endif>
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​{{ ​$item['label'] ​}}
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​</a>
 ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​</li>
 ​ ​ ​ ​ ​ ​ ​ ​@endforeach
 ​ ​ ​ ​</ul>
</nav>

切替先は、現在見ている商品や記事に対応する言語版へ向けます。対応ページがない場合に言語別トップへ移すのか、未提供と案内するのかも決めておきます。

通常のページ遷移で言語を切り替えるだけなら、ライブリージョンを追加する必要はありません。画面遷移を伴わず本文だけを差し替える実装では、更新後の状態をrole='status'で通知し、同時にページタイトル、lang、フォーカス位置も更新します。

日付、時刻、数値、通貨をローカライズする

翻訳文字列と表示形式は別々に扱います。日付は表示ロケールと利用者のタイムゾーンを反映し、金額はロケールに加えて通貨コードを明示します。

Carbonで日時を表示する

use ​Carbon\CarbonImmutable;

$displayDate ​= ​CarbonImmutable::parse($storedUtc, ​'UTC')
 ​ ​ ​ ​->setTimezone($userTimeZone)
 ​ ​ ​ ​->locale(app()->getLocale())
 ​ ​ ​ ​->isoFormat('LLL');

$relativeDate ​= ​CarbonImmutable::parse($storedUtc, ​'UTC')
 ​ ​ ​ ​->locale(app()->getLocale())
 ​ ​ ​ ​->diffForHumans();

保存時のタイムゾーン、表示時のタイムゾーン、文章のロケールを分けると、同じ日時を利用者ごとに説明できます。相対時刻だけでは正確な日時を確認しにくいため、注文履歴などでは絶対日時も併記します。

NumberFormatterで金額を表示する

$settings ​= ​config('locales.'.app()->getLocale());

$formatter ​= ​new ​\NumberFormatter(
 ​ ​ ​ ​$settings['intl_locale'],
 ​ ​ ​ ​\NumberFormatter::CURRENCY
);

$displayPrice ​= ​$formatter->formatCurrency($amount, ​$order->currency);

表示言語が日本語だから日本円、英語だから米ドルと決めることはできません。言語は文章の選択、通貨は価格や契約の条件です。通貨換算が必要な場合は別の業務処理で行い、NumberFormatterは確定した金額の表示に使います。

RTLをレイアウト全体で確認する

RTL(Right ​to ​Left)は、アラビア語やヘブライ語などで使われる右から左への基準方向です。dir='rtl'をhtml要素へ付けても、margin-leftのような物理方向のCSSまで意図どおりに置き換わるとは限りません。

.card ​{
 ​ ​ ​ ​margin-inline-start: ​1rem;
 ​ ​ ​ ​padding-inline: ​1.25rem;
 ​ ​ ​ ​text-align: ​start;
}

[dir='rtl'] ​.icon-forward ​{
 ​ ​ ​ ​transform: ​scaleX(-1);
}

余白、境界線、配置にはinline-startとinline-endを使います。反転するのは進む、戻るなど方向そのものに意味があるアイコンです。再生、電話、チェックマークまで一律に反転させません。

キーボードのフォーカス順は、見た目の左右ではなくDOMの論理順を基準に設計します。CSSのorderや絶対配置だけで並びを反転させると、視覚上の順序と読み上げ順がずれるためです。詳しい確認方法は、キーボード操作とフォーカス表示の実務ガイドも参考になります。

RTLの文章に注文番号などのLTR文字列を埋め込む場合は、周囲の句読点まで崩れないようbdiで分離します。

<p ​dir='rtl' ​lang='ar'>رقم ​الطلب: ​<bdi>ABC-123</bdi></p>

入力エラーと周辺コンテンツも翻訳する

多言語化の対象は本文だけではありません。入力ラベル、補足、エラー、代替テキスト、メール、PDF、404ページも、利用者が手続きを完了するための情報です。

<?php
// ​lang/ja/validation.php
return ​[
 ​ ​ ​ ​'required' ​=> ​':attributeは必須です。',
 ​ ​ ​ ​'email' ​=> ​':attributeの形式を確認してください。',
 ​ ​ ​ ​'attributes' ​=> ​[
 ​ ​ ​ ​ ​ ​ ​ ​'email' ​=> ​'メールアドレス',
 ​ ​ ​ ​ ​ ​ ​ ​'password' ​=> ​'パスワード',
 ​ ​ ​ ​],
];

送信後は、エラーのある入力へaria-describedbyで説明を関連付け、必要に応じてエラー概要へフォーカスを移します。ラベル、エラー概要、フォーカス移動まで含む設計は、フォームと入力UIのアクセシビリティで確認できます。

hreflangは対応URLの一覧から生成する

同じ内容の言語別ページがある場合は、各ページのheadに、自分自身を含むすべての対応ページをhreflangで列挙します。どの言語にも一致しない利用者向けの入口がある場合は、x-defaultも設定できます。

@foreach ​($alternateUrls ​as ​$htmlLang ​=> ​$url)
 ​ ​ ​ ​<link ​rel='alternate' ​hreflang='{{ ​$htmlLang ​}}' ​href='{{ ​$url ​}}'>
@endforeach

<link ​rel='canonical' ​href='{{ ​$canonicalUrl ​}}'>

現在のURLに対する文字列置換で別言語URLを作ると、翻訳先でスラッグが違うページや、一部の言語だけ未公開のページを誤って結び付けます。コンテンツIDや言語別スラッグの対応表から公開済みURLだけを生成し、各言語版が同じ組み合わせを返していることも確認します。

自動テストと実機確認を分担する

自動テストは、設定漏れや回帰を早く見つけるために使います。読み上げの自然さ、フォーカスの追いやすさ、RTL画面の理解しやすさは、自動テストだけでは保証できません。

多言語サイトのテスト分担
確認対象 自動化しやすい確認 人が確認する内容
翻訳 主要キー、未対応ロケール、言語別レスポンス 語調、文字切れ、訳語の統一
HTML lang、dir、見出し、ラベル、代替URL 読み上げ時の発音と意味
操作 切替後のURL、現在状態、エラーとの関連付け キーボード操作とフォーカス表示
RTL 方向属性、論理プロパティ、画像差分 英数字の混在、方向アイコン、操作順

Laravel ​Duskなどでは、属性、URL、フォーカス位置、表示順を確認できます。ただし、スクリーンリーダーが実際にどう発音するかまでは判定できません。NVDA、VoiceOver、TalkBackなど、対象利用環境での確認を別に行います。公開後も改善を続ける体制は、Webアクセシビリティを実務に組み込むチェックポイントで整理しています。

段階的に導入する手順

  1. 対応ロケールとフォールバックを設定し、URLの形式を決める。
  2. 共通レイアウトでlangとdirを出力する。
  3. ナビゲーション、フォーム、エラーなど、手続きに必要な文言から辞書へ移す。
  4. 日付、タイムゾーン、数値、通貨の表示処理を共通化する。
  5. 言語切替と対応URLを実装し、未公開ページの扱いを決める。
  6. キーボード、読み上げ、表示拡大、RTLを含むテストを行う。
  7. 翻訳の追加、変更、削除をコードレビューへ組み込み、公開後も確認する。

実装前後のチェックリスト

Laravel多言語化のまとめ

Laravelの多言語化は、翻訳辞書を置く作業だけでは完了しません。URLで言語を明示し、ロケールを検証し、HTMLの言語と方向を設定したうえで、日付、通貨、入力エラー、言語別URLをそれぞれの責務に分けます。

まずは主要な利用手順を一つ選び、言語切替から手続き完了まで通して検証してください。翻訳、表示形式、アクセシビリティ、検索向け設定を同じ画面で確認すると、問題の所在を切り分けやすくなります。

この記事に関連する株式会社greedenの取り組み

多言語サイトでは、言語切替だけでなく、読み上げやキーボード操作まで含めた確認が欠かせません。株式会社greedenは、Webアクセシビリティサービス「UUU」とチェックサービスを通じ、既存サイトの使いやすさ向上を支援しています。

モバイルバージョンを終了