Master 文件。运行时 dump。管理员可编辑。无需 fork 您的插件源代码。

插件的英文文案位于其源代码树中。翻译后的文案位于 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — 在安装时生成,通过宿主的 Languages UI 编辑,绝不会写回插件的源文件。本页讲解完整的间接机制:add_translation_file REGISTRY Hook、dump 副本去往何处、会破坏 Languages UI 的陷阱,以及来自 acelle/ai 的十八语言约定。

为什么流程是间接的

插件作者在源代码树中编写英文文案(以及可选的若干主要语言翻译)。生产环境的管理员希望在其运行中的安装上编辑这些字符串 — 修复一个错别字、软化某个标签、再翻译一种额外的语言 — 而无需打开插件源代码。两类受众都需要使用同一组键,但他们不能共享同一个文件:在生产实例上编辑源文件会在下次插件升级时被吹掉,而编辑一个镜像源的部署副本,则意味着源版本控制的文件永远看不到那个修复。

插件系统通过运行时 dump 解决这一矛盾。插件交付一份 master 文件(每个逻辑区域一份),位于其源代码的 resources/lang/en/ 下;安装时,宿主会将该 master 复制到 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/,宿主支持的每种语言各一份。dump 出来的副本才是 trans() 在运行时读取的内容;宿主的 Languages 管理员 UI 编辑的是这些 dump 副本;插件源代码保持原样。重新安装插件会重新运行 dump,拾取插件作者新增的任何键 — 而不会覆盖管理员同时编辑过的语言翻译。

五步流程

这是从您的插件源代码到生产环境中渲染字符串的已验证路径:

  1. 插件的 register() 方法调用 Hook::add('add_translation_file', ...),为每个逻辑翻译文件贡献一个描述符(文件路径、语言目录、namespace 前缀)。
  2. 在每次请求时,宿主的 AppServiceProvider::boot() 会调用 Hook::collect('add_translation_file') 并迭代各项贡献,对每一项调用 $this->loadTranslationsFrom()
  3. Plugin::register() 时(在 plugin:init 结束时以及每次成功上传时自动调用),宿主会调用 Language::dump()
  4. Language::dump() 读取每个已注册的描述符,并将 master 文件复制到 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — 宿主支持的每种语言各一次。
  5. 管理员通过 Languages 管理员 UI 编辑 dump 出来的运行时文件。插件 Blade 视图中的 trans() 调用读取那些被编辑过的 dump 副本,而不是插件源 master。

通过 add_translation_file 注册

骨架的 service provider 展示了标准注册写法。每个条目都是一次 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_folderloadTranslationsFrom() 注册 namespace 的位置。必须指向 storage/app/data/plugins/... 下的运行时 dump 路径,而非插件源代码。
translation_prefixBlade 用 trans('prefix::messages.foo') 触达的 namespace 前缀。按惯例采用插件的 name 段以保持唯一。
file_name该条目映射到语言文件夹内的哪个文件。具有多个翻译界面的插件,每个文件注册一个条目。
master_translation_file指向源版本控制 master 文件的绝对路径。Language::dump() 从这里读取;dump 副本写入 translation_folder

为什么是 register(),而不是 boot()

宿主的 AppServiceProvider::boot() 在其自身的 boot 阶段调用 Hook::collect('add_translation_file')。Laravel 会先运行每个 service provider 的 register(),再运行每个 provider 的 boot() — 因此当任何插件的 boot() 运行时,宿主的 collect 循环已经结束。在 boot() 中注册 add_translation_file 条目的插件,其贡献发生在宿主停止查找之后,条目永远不会被拾取。可见症状是 trans('loyalty::messages.intro') 返回字面键 — 无翻译、无回退。

这是唯一一个应放在 register() 中的与翻译相关的 Hook。生命周期 Hook(activate_plugin_*delete_plugin_*)、路由、视图,以及所有其他 Hook 都仍然放在 boot() 中。

双重加载陷阱

直觉上,通过 Hook 注册的同时再在 boot() 里调用一次 Laravel 标准的 $this->loadTranslationsFrom(),似乎是双保险。其实不是 — 那是一次悄无声息的覆盖。

宿主的 collect 循环先运行,把插件的 namespace 指向 storage/app/data/plugins/... 下的运行时 dump 文件夹。插件的 boot() 在宿主之后运行,插件中另一次 loadTranslationsFrom() 调用会把 namespace 重新指向插件传入的任何路径 — 通常是源代码的 resources/lang/ 文件夹。最后一次调用胜出,于是运行时最终直接读取源 master 文件。

可见症状是 Languages UI 中管理员的编辑在运行时不再生效。dump 副本变成僵尸文件:磁盘上存在、被管理员编辑过,但永远不会被读取,因为 namespace 提示指向了别处。这正是 SOURCE_OF_TRUTH 明确点名的陷阱。

仅使用 add_translation_file Hook。不要再在您插件的 boot() 中调用 $this->loadTranslationsFrom()。唯一例外是当您需要一个无 namespace 的查找路径时(acelle/ai 插件这样做,是为了让遗留的 trans('refactor/ai_chatbox.foo') 键在没有 namespace 前缀的情况下继续工作)— 即便如此,也仅将其指向插件源代码的 resources/lang/ 作为回退,而不是指向 dump 路径。

Master 文件 vs 运行时文件 — 两条要记住的路径

路径它是什么
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php Master 文件。位于插件源代码树中。您在新增键或交付新英文文案时编辑它。git commit 跟踪它。Language::dump() 从这里读取。
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php 运行时文件。每种语言一份。由 Language::dump() 在插件安装时以及在 php artisan translation:upgrade 时生成。宿主的 Languages 管理员 UI 编辑这些文件;trans() 从这些文件读取。提交到源代码管理 — 插件作者的英文文案位于 master 中,各语言副本则在每个安装上各自存在。

当您交付一个新增键的插件更新时,您是在源代码中编辑 master 文件。当新版本部署到生产安装时,管理员运行 php artisan translation:upgrade(或下一次 Plugin::register() 调用会自动完成),新键就会在每种语言的运行时文件中浮现,初始翻译值即为英文值。已存在键的现有翻译值会被保留。

拆分为多个翻译文件

一个只有单一逻辑区域(settings、dashboard)的小型插件,使用单一 master messages.php 已经够用。较大的插件能从拆分中受益 — 每个文件在 Languages 管理员 UI 中成为可单独编辑的条目,多个翻译者可以并行处理不同文件而不冲突。模式是每个文件一次 Hook::add('add_translation_file', ...) 调用。

标准示例是 acelle/aistorage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197。该插件注册了九个独立的翻译文件,每个界面一个:

$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",
        ];
    });
}

这种拆分让支持翻译者能够专注于 chatbox 文案而不必触碰管理员审计日志标签 — 也让管理员 UI 能为每个文件呈现一个能在一屏内显示完毕的编辑页,而不是一个 1,000 行的长滚动条。

十八语言约定

AcelleMail 为十八种语言交付翻译:英语、越南语、俄语、韩语、日语、中文、德语、法语、西班牙语、葡萄牙语、意大利语、荷兰语、波兰语、瑞典语、乌克兰语、土耳其语、阿拉伯语、印地语。在 storage/app/data/plugins/acelle/ai/lang/ 内的检查印证了这一模式:17 个语言文件夹与源 en 并列,每个都包含完整的 dump 副本文件集。

插件作者的工作是仅交付一份英文 master 文件。Language::dump() 会通过把英文 master 复制到每个文件夹来创建 17 个非英文语言文件夹 — 每个键以英文值起步,宿主的 Languages 管理员 UI 提供翻译的工作流。您的插件源代码并不要求交付预翻译的语言。当您有机器翻译草稿用于给管理员 UI 预填时这样做无妨,但这并非常态 — 大多数插件仅交付英文,让安装方去翻译。

在插件视图中使用 trans()

Blade 语法与您注册的 translation_prefix 匹配。对于骨架的 'translation_prefix' => 'loyalty'

{{ trans('loyalty::messages.intro') }}


{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}

您插件自己的 controller 和 service 可以使用 __() 并附带相同的 namespace 前缀:

$message = __('loyalty::messages.points_awarded', ['count' => $points]);

当已注册的键无法被解析时(拼写错误、键缺失,或 add_translation_file Hook 在 boot() 而非 register() 中运行),Laravel 会将字面键作为渲染字符串返回。页面上看到 loyalty::messages.intro 就是"翻译没接通"的标志性症状。

translation:upgrade — master 文件编辑后的重新同步

在插件源中编辑 master 文件后 — 新增键、修复英文文案中的错别字 — 插件作者需要让运行时文件拾取变更。有两种方式:

  1. 重新安装插件。Plugin::register() 在其五个步骤中的一步调用 Language::dump()。dump 会保留管理员已经翻译过的键,并以英文 master 值作为初始翻译加入新键。
  2. 直接运行 artisan 命令:php artisan translation:upgrade。效果相同,无需重装插件。在开发期间对 master 文件文案进行迭代时很有用。

两条路径都是非破坏性的 — 管理员编辑过的翻译会存活。行为是"把 master 的新键合并到运行时,保留现有运行时值不变"。一个新的英文键会在每种语言的运行时文件中以英文值出现,等待管理员翻译。

五个反模式

1. 在 boot() 中注册 add_translation_file

在您的 boot() 之前,宿主的 collect 循环已经运行完毕。Hook 成功触发但永远不会被拾取。修复:只有翻译文件注册放在 register() 中;其他一切都留在 boot() 中。

2. 在使用 Hook 的同时调用 $this->loadTranslationsFrom()

把 namespace 重新指向您的源文件夹,杀死运行时的 dump 副本。Languages UI 的管理员编辑变得不可见。修复:仅使用 Hook;如果确实需要一个回退的无 namespace 路径(罕见 — 参见 acelle/ai 案例),将其显式指向插件源代码而不覆盖 namespace 提示。

3. 将 translation_folder 指向插件源代码

与前一个陷阱效果相同,只是路径不同。宿主会将您的 namespace 注册到您传入的任何路径 — 传入源路径,dump 副本就永远不会被读取。修复:始终将 translation_folder 设为 storage/app/data/plugins/{vendor}/{name}/lang/ 下的运行时 dump 路径。

4. 在插件源代码仓库中编辑 dump 副本文件

当您伸手想"就让我翻译这一个字符串"时容易犯错。dump 副本是安装相关的 — 它们位于 storage/app/data/,在每个 AcelleMail 安装上都被 gitignore。在源代码中编辑它们毫无效果;下一次安装会从您的源 master 重新运行 dump(),覆盖您在副本路径中放入的任何内容。修复:如果您想要预翻译,请在源代码的 resources/lang/{locale}/ 下交付预翻译的语言 master;只有在没有特定语言 master 可复制时,dump() 才会从 en 复制。

5. 不带 namespace 前缀的 trans('messages.foo')

Laravel 会根据宿主的语言文件夹解析无 namespace 的键,而那里并不包含您插件的字符串。返回字面键。修复:始终以您注册的 translation_prefix 为前缀:trans('loyalty::messages.foo')

下一步去哪里

翻译在持久化侧合上了"质量"环 — schema 隔离、运行时间接、管理员可编辑性都已就位。接下来的两页讲解插件运行时故事的其余部分:插件生命周期在模型方法层面梳理四种状态(register → activate → disable → delete),测试则讲解让插件在每次宿主构建时都留在 CI 中的 phpunit.xml 接线。