学完这篇你能拿到两套可直接落地的 PHP 多语言方案:gettext 的文件式翻译和数组式翻译,并知道在什么规模、什么团队下该选哪一个。
第一步:先明确两种方案的边界
PHP 做多语言,主流就两条路:
- gettext:PHP 内置扩展,翻译词条存在
.po(源文件)和 .mo(编译后的二进制)里,运行时用 _('Hello') 取值。优点是生态成熟、有 Poedit 这类可视化工具、支持复数规则;缺点是依赖系统 locale,部署时容易在服务器上翻车。
- 数组式:每种语言一个 PHP 文件,
return 一个键值数组,自己写十几行取值函数。优点是零依赖、改完即生效、调试直观;缺点是没有标准工具链,词条多了要靠自觉维护。
选型原则很简单:**词条几百条以内、只有一两个人维护,用数组;词条上千、有翻译人员参与、需要复数规则,用 gettext。**
第二步:把 gettext 跑通
先确认扩展和系统语言包,两者缺一不可:
php -m | grep gettext
locale -a | grep -E 'zh_CN|en_US'
如果没有 locale,装一下(Debian/Ubuntu):
apt install locales
locale-gen zh_CN.UTF-8 en_US.UTF-8
locale-gen --purge
目录结构按 gettext 的约定来,域名(domain)自己定,比如 messages:
locales/
zh_CN/LC_MESSAGES/messages.po
zh_CN/LC_MESSAGES/messages.mo
en_US/LC_MESSAGES/messages.po
en_US/LC_MESSAGES/messages.mo
.po 是给人编辑的,.mo 是给程序读的,改完 .po 必须编译:
msgfmt locales/zh_CN/LC_MESSAGES/messages.po -o locales/zh_CN/LC_MESSAGES/messages.mo
初始化代码建议封装成一个 i18n.php,在入口文件最早处引入:
$lang = $_SESSION['lang'] ?? 'zh_CN';
putenv("LC_ALL={$lang}");
if (!setlocale(LC_ALL, $lang)) {
error_log("locale {$lang} not available");
$lang = 'en_US';
setlocale(LC_ALL, $lang);
}
bindtextdomain('messages', __DIR__ . '/locales');
bind_textdomain_codeset('messages', 'UTF-8');
textdomain('messages');
模板里直接写 <?= _('Hello') ?>,复数用 ngettext('one item', '%d items', $n)。
注意:setlocale 返回 false 说明系统没装这个 locale,此时 gettext 会静默退回英文,页面不会报错,很容易误判成「翻译文件没生效」。上线前一定在目标服务器上跑一次 locale -a 核对。
注意:putenv + setlocale 是进程级设置。在 php-fpm 下每个请求都要重新设置一次,别指望在某个请求里设完就全局生效;常驻型运行环境(Swoole、RoadRunner)里更要小心,请求结束后要还原。
第三步:数组式翻译的最小实现
语言文件放在 lang/ 下,文件名就是语言代码:
// lang/zh_CN.php
return [
'nav.home' => '首页',
'greet' => '你好,%s',
'items' => ['zero' => '没有商品', 'one' => '一件商品', 'other' => '%d 件商品'],
];
取值函数带默认值回退和占位符替换:
function t(string $key, array $args = [], ?string $lang = null): string
{
static $loaded = [];
$lang = $lang ?? ($_SESSION['lang'] ?? 'zh_CN');
if (!isset($loaded[$lang])) {
$file = __DIR__ . "/lang/{$lang}.php";
$loaded[$lang] = is_file($file) ? require $file : [];
}
$text = $loaded[$lang][$key] ?? $key;
return $args ? vsprintf($text, $args) : $text;
}
static 缓存让同一请求内只 require 一次,配合 OPcache 基本没有 IO 开销。
注意:数组式最容易踩的坑是漏词条。缺失时 t() 会原样返回键名,页面上就会出现 nav.home 这种字符串。建议开发环境加一条 error_log,把缺失键记下来,上线前统一补齐。
注意:语言文件必须存成无 BOM 的 UTF-8。带 BOM 时 require 会在输出流里塞进三个字节,表现为页面顶部多出空白或 headers already sent。
第四步:关键维度对比
| 维度 | gettext | 数组式 |
|---|
| 运行依赖 | 需 gettext 扩展 + 系统 locale | 纯 PHP,无依赖 |
| 词条编辑 | Poedit 等工具,支持翻译记忆 | 手写 PHP,易出错 |
| 复数规则 | ngettext 按语言自动处理 | 自己按语言实现 |
| 改词条生效 | 需重新 msgfmt 编译 | 保存即生效 |
| 性能 | C 层查找,快 | 数组查找,也快(有 OPcache) |
| 部署迁移 | 换服务器常因 locale 失败 | 拷贝文件即可 |
| 词条规模 | 上千条无压力 | 几百条以内合适 |
性能上两者差距在实际项目里可以忽略,真正的差别在部署稳定性和协作流程。
第五步:语言切换与缓存处理
不管哪种方案,语言状态建议存在 $_SESSION['lang'],同时写一份 cookie 方便游客下次访问。切换入口做成白名单校验,别直接把 $_GET['lang'] 拼进文件路径:
$allow = ['zh_CN', 'en_US'];
$lang = $_GET['lang'] ?? '';
if (in_array($lang, $allow, true)) {
$_SESSION['lang'] = $lang;
setcookie('lang', $lang, time() + 86400 * 30, '/');
}
如果站点开了页面缓存或 Redis 缓存,缓存键必须带上语言,否则中文页面会被当成英文页面的缓存命中,用户切语言后看到混杂内容。
注意:gettext 在部分环境下会缓存 .mo 文件内容,替换翻译文件后需要重启 php-fpm 才生效。数组式配合 OPcache 时同理,opcache.validate_timestamps=0 的生产环境要手动 reload。
小结
- 小站点、词条少、单人维护:数组式,零依赖、改完即生效,写好缺失键日志即可。
- 要交给翻译人员、词条上千、需要复数规则:gettext,但必须提前在目标服务器装好 locale 并验证。
- gettext 的两个致命点:系统 locale 缺失会静默退回英文;
.po 改完不 msgfmt 等于没改。
- 数组式的两个致命点:漏词条会直接暴露键名;文件带 BOM 会污染输出。
- 语言状态用 session + cookie 存,切换走白名单,缓存键带上语言代码。