php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【総合ガイド】Laravelで実現する多言語(i18n)対応とアクセシビリティ――言語切替・日付通貨表示・RTL対応まで一気通貫

この記事で学べること(先に要点)

  • Laravel の国際化(i18n)機能の基本:resources/lang ディレクトリ、配列・JSON 翻訳、trans/__/trans_choice の使い分け
  • ルーティング/ミドルウェアでの言語切替設計(URL パス/サブドメイン/クッキー)の実装比較とベストプラクティス
  • HTML の langdirhreflang、画面内の部分的 lang 付与など、アクセシビリティの要点
  • Carbon と PHP Intl(NumberFormatter)を用いた日付・時刻・数値・通貨のローカライズ
  • 右から左(RTL)言語の安全なサポート(レイアウト・コンポーネント・アイコンの配慮)
  • 検証しやすいプロジェクト構成、Dusk を用いた読み上げ・切替テスト観点、執筆ルールの整備

想定読者(だれが得をする?)

  • Laravel 初~中級:まずは**2言語(例:日本語/英語)**対応を短期間で導入したい個人・小規模チーム
  • 受託/自社SaaSのテックリード:URL 設計・翻訳運用・QA体制をスケールさせたい方
  • デザイナー/テクニカルライター:文体・UIコピー・代替テキストを一貫運用したい方
  • アクセシビリティ担当/QA:**言語宣言・読み上げ・方向性(LTR/RTL)**の網羅検証を仕組み化したい方

アクセシビリティレベル:★★★★★

HTML の langdir、部分的言語指定、hreflang、言語切替 UI のラベリング、aria-live による切替通知、RTL 配慮(フォーカス順・アイコン反転)まで、実装例でカバーします。


1. はじめに:なぜ「i18n × アクセシビリティ」を同時に進めるのか

多言語対応は「文字を翻訳すれば完了」ではありません。実際にはURL/ルーティング・画面構造・読み上げ・日付通貨表示などが絡み合い、ユーザーが迷わない体験を設計する必要があります。
Laravel は resources/lang の翻訳仕組み、強力なミドルウェア、Blade レイアウトとの相性が良く、i18n とアクセシビリティ(a11y)を同時に前進させるのに最適です。

押さえておきたい前提:

  • ユーザーが選んだ言語は最優先:自動判定は補助的に
  • URL で言語が判別できる設計が理想(共有・SEO・デバッグが容易)
  • 画面には常に lang と場合によって dir を明示
  • 画像やアイコン、並び順、通知タイミングにも言語・文化圏の前提が潜む

この記事は、今日からプロダクションに導入できる最小構成から、大規模運用に効く拡張まで順を追ってご案内しますね♡


2. 翻訳の置き場と呼び出し:配列 vs JSON、複数形、プレースホルダ

2.1 ディレクトリ構造と基本

resources/
└─ lang/
   ├─ en/
   │  ├─ auth.php
   │  ├─ pagination.php
   │  ├─ validation.php
   │  └─ app.php        // アプリ固有の文言
   ├─ ja/
   │  ├─ auth.php
   │  ├─ pagination.php
   │  ├─ validation.php
   │  └─ app.php
   ├─ en.json           // JSON 翻訳(キー=原文)
   └─ ja.json
  • 配列翻訳(app.php など)__('app.save') のように名前付きキーで参照
  • JSON 翻訳:原文をキーとして __('Save')"Save": "保存" のように置き換え
    • フロントで文言管理が難しい場合は「まず JSON」で運用開始が容易
    • 大規模化・辞書化したいときに配列翻訳へ移行(名前付きキーで整理)

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

// resources/lang/en/app.php
return [
  'welcome_user' => 'Welcome, :name!',
  'cart_items'   => '{0} Your cart is empty|{1} You have :count item|[2,*] You have :count items',
];

// resources/lang/ja/app.php
return [
  'welcome_user' => ':name さん、ようこそ!',
  'cart_items'   => '{0} カートは空です|{1} :count 件の商品があります|[2,*] :count 件の商品があります',
];
<p>{{ __('app.welcome_user', ['name' => $user->name]) }}</p>
<p>{{ trans_choice('app.cart_items', $count, ['count' => $count]) }}</p>
  • trans_choice で複数形ルールに対応(日本語は 0/1/2+ を同じ文でも定義可)
  • プレースホルダは必ず指定名を揃えるname/count など)

3. URL 設計とミドルウェア:どこで言語を決める?

3.1 推奨:URL パスに言語コードを付ける

  • 例:/ja/products/en/products
  • 利点:共有やブックマークに強い、SEO の hreflang と相性が良い、CDN キャッシュ切り分けが容易
  • 欠点:URL が少し長くなる

3.2 設定例(ルート+ミドルウェア)

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

Route::middleware(SetLocaleFromUrl::class)->group(function () {
    Route::get('/{locale}', fn ($locale) => view('welcome'))->name('home');

    Route::prefix('{locale}')
        ->where(['locale' => 'ja|en|ar']) // サポート言語を明示
        ->group(function () {
            Route::get('/products', [ProductController::class, 'index'])->name('products.index');
            // ... 他のルート
        });
});
// app/Http/Middleware/SetLocaleFromUrl.php
namespace App\Http\Middleware;

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

class SetLocaleFromUrl
{
    public function handle(Request $request, Closure $next)
    {
        $supported = ['ja','en','ar']; // 中央集約してもOK
        $locale = $request->route('locale');

        if (! in_array($locale, $supported, true)) {
            // フォールバック(例:Accept-Language を見て最適言語に 302 リダイレクト)
            $locale = 'ja';
            return redirect()->to("/{$locale}".$request->getRequestUri());
        }

        App::setLocale($locale);

        // Carbon/Translator 等の連携(後述)
        \Carbon\Carbon::setLocale($locale);

        return $next($request);
    }
}

代替案

  • サブドメイン(ja.example.com):国/地域ごとに配信を分けたいケースで有効
  • クッキー:URL を変えたくない単一ドメイン向け(ただし共有・SEO メリットは薄い)

3.3 Blade レイアウトで langdir を出し分け

{{-- resources/views/layouts/app.blade.php --}}
@php
  $locale = app()->getLocale();
  $dir = in_array($locale, ['ar','he','fa']) ? 'rtl' : 'ltr';
@endphp
<!doctype html>
<html lang="{{ $locale }}" dir="{{ $dir }}">
<head>
  <meta charset="utf-8">
  <title>@yield('title', __('app.site_title'))</title>
  {{-- hreflang(任意:後述) --}}
</head>
<body>
  <a class="skip-link" href="#main">Skip to content</a>
  <nav aria-label="{{ __('app.language_switcher') }}">
    <ul class="inline-flex gap-2">
      <li><a href="{{ route(Route::currentRouteName(), ['locale'=>'ja'] + request()->route()->parameters()) }}"
             hreflang="ja" lang="ja" @class(['underline'=> $locale==='ja'])>日本語</a></li>
      <li><a href="{{ route(Route::currentRouteName(), ['locale'=>'en'] + request()->route()->parameters()) }}"
             hreflang="en" lang="en" @class(['underline'=> $locale==='en'])>English</a></li>
      <li><a href="{{ route(Route::currentRouteName(), ['locale'=>'ar'] + request()->route()->parameters()) }}"
             hreflang="ar" lang="ar" dir="rtl" @class(['underline'=> $locale==='ar'])>العربية</a></li>
    </ul>
    <div id="lang-live" class="sr-only" aria-live="polite">
      {{ __('app.current_language_is', ['lang' => $locale]) }}
    </div>
  </nav>

  <main id="main">@yield('content')</main>
</body>
</html>

アクセシビリティの要点

  • ページ全体の言語を <html lang="ja"> のように必ず明示
  • RTL は dir="rtl"方向性を示す(<html> で全体、<span lang="..."> で部分的)
  • 言語切替ナビには aria-label を付け、押すと何が起きるかを明確に
  • 現在の言語を lang-live のライブリージョンで読み上げ(切替直後の気づき)

4. 翻訳辞書の運用ルール:名前付きキーとレビューの型

運用が進むほど「どのキーがどの画面で使われているかわからない」問題が起きます。以下の原則で辞書の可読性を保ちましょう。

  1. ドメイン別のファイルに分割:auth.phpnav.phperrors.phpemails.php など
  2. キーは機能名+用途nav.settings.accounterrors.generic
  3. UI コピーは文章の使い回しを避ける:文脈依存の語尾/敬語が壊れるため
  4. 翻訳レビューの型:新規/変更/削除ごとに Pull Request で文調と読み上げを確認
  5. テストassertSee だけでなく assertSeeInOrder読み順も検証

5. 日付・時刻・数値・通貨のローカライズ

5.1 Carbon で日付と相対表現

use Carbon\Carbon;

Carbon::setLocale(app()->getLocale());

$dt = Carbon::parse('2025-08-27 15:30:00');

$long  = $dt->isoFormat('LL');       // ja: 2025年8月27日 / en: August 27, 2025
$short = $dt->isoFormat('lll');      // ja: 2025年8月27日 15:30 / en: Aug 27, 2025 3:30 PM
$diff  = $dt->diffForHumans();       // ja: 〇〇前 / en: x minutes ago
  • isoFormat はロケールごとの自然な表記に合わせられる
  • diffForHumans は**「○分前」**のような相対表現も自動でローカライズ

5.2 PHP Intl の NumberFormatter で数値と通貨

$locale = app()->getLocale();

$fmt = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
$price = $fmt->formatCurrency(1234.5, $locale === 'ja' ? 'JPY' : 'USD'); // ¥1,235 / $1,234.50

$fmtInt = new \NumberFormatter($locale, \NumberFormatter::DECIMAL);
$count  = $fmtInt->format(1234567.89); // ja: 1,234,567.89 / ar: ١٬٢٣٤٬٥٦٧٫٨٩
  • 桁区切り、通貨記号、小数点記号は文化圏ごとに異なる
  • 価格表示は通貨とロケールを組にして扱う(JPY と en を混ぜてはダメ)

5.3 曜日・タイムゾーン・週の開始日

  • 週の開始曜日(日本は月曜が一般的、米国は日曜)はカレンダー UIで差が出やすい
  • タイムゾーンはユーザー設定(プロファイル)を設け、Carbon の tz() で出力

6. 右から左(RTL)言語の実装ポイント

6.1 CSS とコンポーネント指針

  • dir="rtl" のとき、マージン・パディング・フロートは左右が反転
  • 可能なら論理プロパティを使用:margin-inline-start / padding-inline-end など
  • アイコン(矢印、進む/戻る)は RTL で向きを反転(SVG を transform: scaleX(-1)
[dir="rtl"] .icon-arrow-right { transform: scaleX(-1); }

6.2 フォーカス順とキーボード

  • Tab の視線移動方向が LTR と逆になることが多い。
  • コンポーネントをソース順で論理的に配置し、左右依存の絶対配置を避ける。

6.3 混在テキスト(英数字を含むアラビア語等)

  • メタデータ・コード・SKU など強制 LTRにしたい部分は <span dir="ltr">ABC-123</span>
  • 逆に部分的にアラビア語を差し込む場合は <span lang="ar" dir="rtl">...</span>

7. 言語切替 UI:誤解されない見せ方

避ける

  • 国旗アイコン=言語を表さない(スイスの公用語は複数、英語の国旗は存在しない)
  • ISO コードのみの表示(en, ja)は識別に弱い

推奨

  • **自己言及名(エンドニム)**で表示:日本語 / English / العربية
  • 切替リンクには langhreflang を付け、スクリーンリーダーと SEO 両面に親切
  • 切替後は aria-live 領域で現在の言語を案内(前掲 lang-live

サンプル文言(辞書):

// resources/lang/ja/app.php
return [
  'language_switcher'  => '言語を選択',
  'current_language_is'=> '現在の表示言語は :lang です。',
  'site_title'         => 'サンプルサイト',
];

8. 検証:Validation メッセージと attributes() の翻訳

Laravel 標準の validation.php を活用し、項目名を自然言語に。

// resources/lang/ja/validation.php(一部)
return [
  'required' => ':attribute は必須です。',
  'email'    => ':attribute の形式が正しくありません。',
  'attributes' => [
    'email'    => 'メールアドレス',
    'password' => 'パスワード',
  ],
];
  • FormRequest::attributes()二重管理しない方針でどちらかに統一
  • 言語切替後もエラー文が切り替わるよう、検証→リダイレクト→再描画の流れを維持
  • エラー要素には role="alert"、入力には aria-invalid="true"aria-describedby を付与(フォーム a11y の基本)

9. SEO・共有観点:hreflang と正規化

複数言語ページを提供する場合は、各ページの <head> に対応する hreflang を明示します(検索エンジンが言語別ページを正しく認識)。

{{-- layouts/app.blade.php の <head> 内 --}}
@php
  $alts = [
    'ja' => url()->current().(str_contains(url()->current(), '/ja') ? '' : ''), // 例:生成関数で管理
    'en' => preg_replace('#/ja/#','/en/', url()->current()),
    'ar' => preg_replace('#/ja/#','/ar/', url()->current()),
  ];
@endphp
<link rel="alternate" hreflang="ja" href="{{ $alts['ja'] }}">
<link rel="alternate" hreflang="en" href="{{ $alts['en'] }}">
<link rel="alternate" hreflang="ar" href="{{ $alts['ar'] }}">
<link rel="alternate" hreflang="x-default" href="{{ $alts['en'] }}">
  • 自動リダイレクト(Accept-Language だけで言語を決めて 301/302)は、ブックマークや QA を困難にしがち。初回のみ提案に留めると安全。

10. 具体例:多言語対応の最小アプリ(抜粋)

10.1 ルートとコントローラ

// routes/web.php
Route::middleware(\App\Http\Middleware\SetLocaleFromUrl::class)
  ->prefix('{locale}')
  ->where(['locale'=>'ja|en|ar'])
  ->group(function () {
    Route::get('/', fn() => view('home'))->name('home');
    Route::get('/about', [PageController::class,'about'])->name('about');
  });
// app/Http/Controllers/PageController.php
class PageController {
  public function about() {
    $team = [
      ['name'=>'Aiko', 'role'=>__('app.roles.designer')],
      ['name'=>'Ken',  'role'=>__('app.roles.engineer')],
    ];
    return view('about', compact('team'));
  }
}
// resources/lang/en/app.php
return [
  'roles' => [
    'designer' => 'Designer',
    'engineer' => 'Engineer',
  ],
  'about_heading' => 'About us',
  'intro' => 'We build accessible products for everyone.',
];

// resources/lang/ja/app.php
return [
  'roles' => [
    'designer' => 'デザイナー',
    'engineer' => 'エンジニア',
  ],
  'about_heading' => '私たちについて',
  'intro' => 'すべての人にやさしいプロダクトを作っています。',
];

10.2 ビュー(見出し・本文・表)

{{-- resources/views/about.blade.php --}}
@extends('layouts.app')
@section('title', __('app.about_heading'))

@section('content')
  <h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">
    {{ __('app.about_heading') }}
  </h1>
  <p class="mb-6">{{ __('app.intro') }}</p>

  <table class="w-full border-collapse" aria-describedby="page-title">
    <thead>
      <tr><th class="text-left p-2">{{ __('Name') }}</th><th class="text-left p-2">{{ __('Role') }}</th></tr>
    </thead>
    <tbody>
      @foreach ($team as $member)
        <tr class="border-b">
          <td class="p-2">{{ $member['name'] }}</td>
          <td class="p-2">{{ $member['role'] }}</td>
        </tr>
      @endforeach
    </tbody>
  </table>
@endsection
  • __('Name') のようにJSON 翻訳で原文をキーにする例
  • ページ見出しに tabindex="-1" を付け、遷移時に初期フォーカスで読み上げを促す

11. 画像・メディアの言語対応:代替テキストと字幕

  • 代替テキスト(altはコンテンツ言語に合わせて翻訳し、画像内テキストはテキストとして提供(画像のみは NG)
  • 動画には字幕文字起こし。音声説明(音の重要な情報)も字幕で表現
  • 図表はキャプション要約を併記(長文は詳細ページへリンク)

サンプル:

<figure>
  <img src="/img/hero-ja.png" alt="@lang('より速く、よりアクセシブルに')">
  <figcaption>@lang('プロダクトの理念を表現したヒーローイラストです。')</figcaption>
</figure>

12. Eメール・PDF・エラーページ:周辺コンテンツのローカライズ

  • メール:Mailable で ->locale($locale) を活用し、件名・本文・テンプレートを出し分け
  • PDF:生成時のフォント・禁則処理・縦書き(日本語)など、レンダラの言語対応を確認
  • エラーページresources/views/errors/404.blade.php などを言語別条件で分岐langやさしい説明&次アクションを提示

13. アクセシビリティ・チェックリスト(i18n 版)

言語宣言

  • [ ] <html lang="..."> を全ページで設定
  • [ ] RTL 言語で dir="rtl" を設定、論理プロパティを使用
  • [ ] コンテンツ内の別言語に <span lang="..."> を付与

言語切替

  • [ ] URL、サブドメイン、クッキーのいずれかで明示的に切替
  • [ ] 切替 UI に aria-labellang を設定
  • [ ] 切替後に aria-live現在の言語を通知

翻訳運用

  • [ ] ドメイン別ファイルと名前付きキーで可視化
  • [ ] trans_choice による複数形を検証
  • [ ] 代替テキスト・ラベル・ヘルプテキストも翻訳対象に含める

日付・数値

  • [ ] Carbon のロケール設定、isoFormatdiffForHumans を使用
  • [ ] Intl で通貨・小数点・桁区切りをローカライズ

SEO/共有

  • [ ] hreflang を設定し、x-default も用意
  • [ ] 自動リダイレクトは控えめ(初回提案)

14. テストと QA:自動化と実機確認の両輪

14.1 Feature テスト(辞書とレスポンス)

// tests/Feature/LocalizationTest.php
namespace Tests\Feature;

use Tests\TestCase;

class LocalizationTest extends TestCase
{
    /** @test */
    public function it_renders_japanese_homepage()
    {
        $res = $this->get('/ja');
        $res->assertOk()
            ->assertSee('日本語')        // 言語切替 UI
            ->assertSee('私たちについて'); // 見出しの一例
    }

    /** @test */
    public function it_renders_english_homepage()
    {
        $res = $this->get('/en');
        $res->assertOk()
            ->assertSee('English')
            ->assertSee('About us');
    }
}

14.2 Dusk(E2E)

  • 言語切替リンクをクリック→<html lang="...">dir の更新を検証
  • 切替直後に aria-live 領域が読み上げられるか
  • RTL で矢印やパンくずが視覚的にも論理的順序になっているか

14.3 実機読み上げ(推奨)

  • NVDA/JAWS/VoiceOver/TalkBack で見出し・リンク・表を巡回し、言語と方向が正しく反映されるか確認

15. よくある落とし穴と対処

  • 「英語 UI に日本円」:通貨は**市場(ユーザー設定)**に合わせ、記号・桁区切り・税表示を統一
  • 翻訳漏れ:未翻訳キーは原文のまま出すポリシーにして早期発見(JSON 翻訳が有効)
  • 画像内テキスト:将来的な多言語差し替えが困難。テキストを重ねる設計に変更
  • 自動翻訳頼み:技術用語・サポート文言は誤訳しやすい。レビューフロー必須
  • RTL のみ別 CSS:分岐が増えて保守が困難。論理プロパティユーティリティクラスで共通化

16. チーム運用:辞書と UI コピーをプロダクト資産に

  • **単一の「文言リポジトリ」**を持ち、開発と翻訳を Pull Request で結合
  • デザインツールのテキストスタイル名を辞書キーに寄せ、設計と実装の境界を薄くする
  • 翻訳の責務分担:用語集(スタイルガイド)、UI コピー、メール、法的文書(リーガルレビュー)
  • リリース前チェック:**改行位置・禁則・約物・句読点(。、)**の統一

17. まとめ:i18n × a11y は「誰ひとり取り残さない」ための土台

  • URL 設計+ミドルウェアで言語を明示的に制御し、langdir を適切に出力
  • 翻訳は配列/JSON を使い分けtrans_choice で複数形をサポート
  • Carbon/Intl で日付・時刻・通貨を自然な表記に
  • 言語切替 UI とライブリージョンでユーザーの現在地を伝え、RTL でも迷わない画面構造を維持
  • テストとレビューの仕組みを整え、辞書とコピーを資産化

これらは、視覚・聴覚・運動・認知の多様性に配慮するアクセシビリティの実践と同じ方向を向いています。言語の壁と利用文脈の壁を同時に低くすることが、プロダクトの信頼と愛着を育てます。ぜひ、本記事のサンプルを叩き台に、あなたの Laravel プロジェクトで “伝わる” 多言語体験 を育ててくださいね。わたしも応援しています♡


このガイドが特に役立つ読者像(詳細)

  • SaaS/EC のプロダクトマネージャー:海外展開や外国語話者のカスタマーサポート短縮を狙う。URL 設計と辞書運用の標準化ができ、開発/CS/マーケの連携が滑らかに。
  • 受託開発のテックリード:小規模から始めて段階的にスケールする i18n を導入。複数言語の QA 観点(読み上げ、RTL、通貨)をテンプレ化し、コスト・リスクを可視化
  • デザイナー/テクニカルライター:UI コピー・代替テキスト・字幕の一貫性を運用しやすく。辞書キーとコンポーネントの対応が明瞭で、修正の波及を最小化。
  • アクセシビリティ担当/QAlang/dir/hreflang・ライブリージョン・RTL 対応のチェックリストを自動化+実機で回し、誰も取り残さない多言語体験を品質保証。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)