なぜフローが間接的なのか
プラグイン作者は英語 (および任意でいくつかの主要翻訳) をソースツリーに書きます。一方、本番運用の管理者は、プラグインのソースコードに一切触れずに、稼働中のインストールで文字列を編集したいと考えます — タイポを直したり、ラベルを和らげたり、追加ロケールを翻訳したり。両者は同じキー群を扱う必要がありますが、同じファイルを共有することはできません。本番インスタンスでソースを編集してもプラグインの次回アップグレードで上書きされてしまい、ソースを反映したデプロイ済みコピーを編集してもソース管理されたファイルにはその修正が届かないからです。
プラグインシステムはこの問題をランタイムダンプで解決します。プラグインは マスターファイル (論理エリアごとに 1 つ) をソースの resources/lang/en/ 配下に同梱し、インストール時にホストはそれをホスト対応の全ロケール分、storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ へコピーします。ランタイムで trans() が読むのはこのダンプ複製、ホストの Languages 管理 UI が編集するのもこのダンプ複製、プラグインソースはそのまま変更されません。プラグインを再インストールするとダンプが再実行され、プラグイン作者が追加した新規キーを取り込みつつ、その間に管理者が編集したロケール翻訳を上書きすることはありません。
5 ステップのフロー
以下が、プラグインソースから本番でレンダリングされる文字列までの検証済みパスです:
- プラグインの
register() メソッドが Hook::add('add_translation_file', ...) を呼び出し、論理的な翻訳ファイル 1 つにつき 1 ディスクリプタ (ファイルパス、ロケールフォルダ、ネームスペースプレフィックス) を貢献します。
- リクエストごとに、ホストの
AppServiceProvider::boot() が Hook::collect('add_translation_file') を呼び出して貢献を反復し、それぞれに対して $this->loadTranslationsFrom() を呼びます。
Plugin::register() (plugin:init の末尾やアップロード成功時に自動で呼ばれます) で、ホストは Language::dump() を呼びます。
Language::dump() は登録済みの各ディスクリプタを読み、マスターファイルを storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ へコピーします — ホスト対応のロケールごとに 1 回ずつ。
- 管理者は Languages 管理 UI からダンプされたランタイムファイルを編集します。プラグインの Blade ビューにある
trans() 呼び出しが読むのは編集済みダンプ複製であり、プラグインソースのマスターを読むことは決してありません。
add_translation_file での登録
スケルトンのサービスプロバイダに、正規の登録方法が示されています。各エントリは単一の REGISTRY 貢献です:
// In ServiceProvider::register() ← MUST be register, not boot
Hook::add('add_translation_file', function () {
return [
'id' => '#acmecorp/loyalty_translation_file',
'plugin_name' => 'acmecorp/loyalty',
'file_title' => 'Translation for acmecorp/loyalty plugin',
'translation_folder' => storage_path('app/data/plugins/acmecorp/loyalty/lang/'),
'translation_prefix' => 'loyalty',
'file_name' => 'messages.php',
'master_translation_file' => realpath(__DIR__ . '/../resources/lang/en/messages.php'),
];
});
ディスクリプタの各キーには役割があります:
| キー | ホストがこのキーで何をするか |
id | エントリの安定識別子 — Languages 管理 UI は id でファイルをグルーピングします。 |
plugin_name | {vendor}/{name} のプラグイン識別。管理 UI が翻訳エントリと所有プラグインをリンクするために使います。 |
file_title | 管理 UI の編集可能な文字列リスト上に表示される、人間可読のラベル。 |
translation_folder | loadTranslationsFrom() がネームスペースを登録する場所。storage/app/data/plugins/... 配下の ダンプされたランタイムパス を指す必要があり、プラグインソースを指してはいけません。 |
translation_prefix | Blade から trans('prefix::messages.foo') でアクセスするネームスペースプレフィックス。慣習として、ユニーク性を保つためプラグインの name セグメントを使います。 |
file_name | ロケールフォルダ内のどのファイルにこのエントリを対応させるか。複数の翻訳サーフェスを持つプラグインは、ファイルごとに 1 エントリ登録します。 |
master_translation_file | ソース管理されたマスターファイルへの絶対パス。Language::dump() はここから読み、ダンプ複製は translation_folder に書き出されます。 |
なぜ register() で、boot() ではないのか
ホストの AppServiceProvider::boot() は、自身の boot フェーズで Hook::collect('add_translation_file') を呼び出します。Laravel はまず全サービスプロバイダの register() を実行し、その後で全プロバイダの boot() を実行します — そのため、プラグインの boot() が走る時点ではホストの collect ループはすでに終了しています。プラグインが add_translation_file エントリを boot() で登録すると、ホストが見終わった後 に貢献することになり、エントリは決して拾われません。可視の症状としては、trans('loyalty::messages.intro') がリテラルキーを返します — 翻訳もフォールバックもありません。
これは register() に書く唯一の翻訳関連フックです。ライフサイクルフック (activate_plugin_*、delete_plugin_*)、ルート、ビュー、その他すべてのフックは boot() に残します。
二重ロードの落とし穴
フック経由で登録した上で、Laravel 標準の $this->loadTranslationsFrom() も boot() で呼べば「二重に保険をかけられる」と直感的に考えがちです。しかし実際にはそうではありません — サイレントな上書きになります。
まずホストの collect ループが走り、プラグインのネームスペースを storage/app/data/plugins/... 配下のダンプ済みランタイムフォルダに向けます。プラグインの boot() はホストの後に走るため、プラグインからの もう一度 の loadTranslationsFrom() 呼び出しが、プラグインが渡したパス — 通常はソースの resources/lang/ フォルダ — にネームスペースを向け直してしまいます。最後の呼び出しが勝つので、ランタイムは結局ソースのマスターファイルを直接読むことになります。
可視の症状は、Languages UI での管理者編集がランタイムに反映されなくなることです。ダンプ複製はゾンビファイルと化します: ディスク上に存在し、管理者に編集されているのに、ネームスペースヒントが別の場所を指しているため決して読まれません。これが SOURCE_OF_TRUTH が名指しで警告している落とし穴です。
add_translation_file フックのみを使ってください。 プラグインの boot() から $this->loadTranslationsFrom() を併せて呼ぶことはしないでください。唯一の例外は、ネームスペースなしのルックアップパスが必要なケース (acelle/ai プラグインは、レガシーな trans('refactor/ai_chatbox.foo') キーをネームスペースプレフィックスなしで動かし続けるためにこうしています) で、それでもフォールバック専用としてプラグインソースの resources/lang/ を指すのみとし、ダンプパスを指してはいけません。
マスターファイルとランタイムファイル — 覚えるべき 2 つのパス
| パス | それが何か |
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php |
マスターファイル。 プラグインのソースツリーに置きます。新規キー追加や新しい英語コピーの提供時に編集するのはこちらです。git commit で追跡され、Language::dump() はここから読み取ります。 |
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php |
ランタイムファイル。 ロケールごとに 1 つ。プラグインインストール時と php artisan translation:upgrade 実行時に Language::dump() によって生成されます。ホストの Languages 管理 UI がこれを編集し、trans() はこれを読みます。ソース管理には含めません — プラグイン作者の英語コピーはマスターに、ロケールコピーは各インストール上で個別に存在します。 |
新しいキーを追加するプラグインアップデートを提供するときは、ソースのマスターファイルを編集します。新しいビルドが本番インストールにデプロイされた後、管理者が php artisan translation:upgrade を実行する (または次の Plugin::register() 呼び出しが自動的に実行する) と、その新規キーはすべてのロケールのランタイムファイルに英語の値を初期翻訳として現れます。既存のキーに対する既翻訳の値は保持されます。
複数の翻訳ファイルに分割する
論理エリアが 1 つだけ (settings、dashboard など) の小さなプラグインなら、単一のマスター messages.php で十分です。大きなプラグインでは分割が有益です — 各ファイルが Languages 管理 UI 上で個別に編集可能なエントリとなり、複数の翻訳者が衝突せずに別々のファイルを並行作業できます。パターンはファイルごとに Hook::add('add_translation_file', ...) を 1 回呼ぶことです。
正典的な例は acelle/ai の storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197 です。このプラグインはサーフェスごとに 9 個の翻訳ファイルを個別登録しています:
$aiLangFiles = [
'ai_rewrite',
'ai_chatbox',
'ai_chatbox_prompts',
'ai_chatbox_wait',
'ai_subject_ab',
'ai_settings',
'admin_ai_usage',
'admin_ai_audit',
'admin_ai_permissions',
];
foreach ($aiLangFiles as $file) {
Hook::add('add_translation_file', function () use ($file) {
return [
'id' => "acelle_ai_{$file}",
'plugin_name' => 'acelle/ai',
'file_title' => 'AI — ' . ucfirst(str_replace('_', ' ', $file)),
'translation_folder' => __DIR__ . '/../resources/lang',
'file_name' => "refactor/{$file}.php",
'master_translation_file' => __DIR__ . "/../resources/lang/default/refactor/{$file}.php",
];
});
}
この分割により、サポート翻訳者は管理者監査ログのラベルに触れずにチャットボックスのコピーを作業できますし、管理 UI は 1,000 行のスクロールではなく 1 画面に収まるファイル単位の編集ページを提供できます。
18 ロケール規約
AcelleMail は 18 のロケールに対する翻訳を提供しています: 英語、ベトナム語、ロシア語、韓国語、日本語、中国語、ドイツ語、フランス語、スペイン語、ポルトガル語、イタリア語、オランダ語、ポーランド語、スウェーデン語、ウクライナ語、トルコ語、アラビア語、ヒンディー語。storage/app/data/plugins/acelle/ai/lang/ 配下を確認するとこのパターンが裏付けられます: ソースの en と並んで 17 個のロケールフォルダが存在し、それぞれにダンプ複製された全ファイルが揃っています。
プラグイン作者の仕事は 英語 のマスターファイルだけを提供することです。Language::dump() が英語マスターを各ロケールへコピーすることで、17 の非英語ロケールフォルダが作成されます — すべてのキーが英語の値で始まり、ホストの Languages 管理 UI が翻訳ワークフローを提供します。プラグインソースに事前翻訳済みロケールを同梱する必要はありません。機械翻訳のドラフトで管理 UI を初期化したい場合は同梱しても構いませんが、それは標準ではありません — 多くのプラグインは英語のみを提供し、インストール側で翻訳させます。
プラグインビューでの trans() の使い方
Blade の構文は登録した translation_prefix に合わせます。スケルトンの 'translation_prefix' => 'loyalty' の場合:
{{ trans('loyalty::messages.intro') }}
{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}
プラグイン自身のコントローラやサービスからは、同じネームスペースプレフィックスを付けて __() を使えます:
$message = __('loyalty::messages.points_awarded', ['count' => $points]);
登録済みキーが解決できない場合 (タイポ、キーの欠落、または add_translation_file フックが register() ではなく boot() で動いた場合)、Laravel はリテラルキーをレンダリング文字列として返します。ページ上に loyalty::messages.intro が見えるのは「翻訳が結線されていない」典型的な症状です。
translation:upgrade — マスターファイル編集後の再同期
プラグインソースのマスターファイルを編集した後 — 新規キー追加、英語コピーのタイポ修正など — プラグイン作者はランタイムファイルに変更を取り込ませる必要があります。方法は 2 つ:
- プラグインを再インストールする。
Plugin::register() は 5 つのステップの 1 つとして Language::dump() を呼びます。ダンプは管理者が既に翻訳済みのキーを保持しつつ、新規キーには英語マスターの値を初期翻訳として追加します。
- Artisan コマンドを直接実行する:
php artisan translation:upgrade。効果は同じで、プラグインの再インストールは不要です。開発時にマスターファイルのコピーを繰り返し改修する際に便利です。
どちらのパスも非破壊的です — 管理者が編集した翻訳は残ります。動作は「マスターからランタイムへ新規キーをマージし、既存のランタイム値はそのまま」です。新しい英語キーは全ロケールのランタイムファイルに英語の値とともに現れ、管理者の翻訳を待ちます。
5 つのアンチパターン
1. add_translation_file を boot() で登録する
ホストの collect ループはあなたの boot() より前にすでに実行済みです。フックは正常に発火しますが、決して拾われません。修正: 翻訳ファイルの登録のみを register() に置き、それ以外は boot() に残します。
2. フックと並行して $this->loadTranslationsFrom() を呼ぶ
ネームスペースをソースフォルダに向け直してしまい、ランタイムでダンプ複製を殺します。Languages UI での管理者編集が見えなくなります。修正: フックのみを使うこと。フォールバック用のネームスペースなしパスが本当に必要なら (まれです — acelle/ai のケースを参照)、ネームスペースヒントを上書きせずに明示的にプラグインソースを指してください。
3. translation_folder をプラグインソースに向ける
異なる経路ですが、前の落とし穴と同じ結果になります。ホストはあなたが渡したパスに対してネームスペースを登録します — ソースパスを渡すと、ダンプ複製は決して読まれません。修正: translation_folder は常に storage/app/data/plugins/{vendor}/{name}/lang/ 配下のダンプされたランタイムパスに設定してください。
4. プラグインソースリポジトリ内でダンプ複製ファイルを編集する
「ちょっとこの 1 つの文字列だけ翻訳しよう」と手を伸ばしたときに陥りやすいミスです。ダンプ複製はインストール固有です — storage/app/data/ 配下にあり、すべての AcelleMail インストールで gitignore されます。ソース上で編集しても効果はなく、次のインストール時に dump() がソースマスターから再実行され、複製パスに置いたものは上書きされます。修正: 事前翻訳を提供したい場合は、ソースの resources/lang/{locale}/ 配下に事前翻訳済みロケールマスターを同梱してください。コピー元となるロケール固有のマスターがない場合に限り、dump() は en からコピーします。
5. ネームスペースプレフィックスなしの素の trans('messages.foo')
Laravel はネームスペースなしのキーをホストの lang フォルダに対して解決しますが、そこにはあなたのプラグインの文字列はありません。リテラルキーが返ります。修正: 登録した translation_prefix を必ず付けてください: trans('loyalty::messages.foo')。
次に読むページ
翻訳は永続化サイドの「品質」ループを閉じます — スキーマ分離、ランタイム間接化、管理者編集可能性のすべてが揃いました。次の 2 ページではプラグインのランタイムストーリーの残りを扱います: プラグインライフサイクル はモデルメソッドレベルで 4 つの状態 (register → activate → disable → delete) を辿り、テスト はホストの各ビルドでプラグインを CI に乗せ続けるための phpunit.xml の結線を扱います。