学完这篇,你能在半小时内给自己的 PHP 站点挑一套合适的国际化(i18n)方案,并且提前知道每种方案会在哪里坑到你——不用装一堆扩展,也不用重构整个项目。
第一步:先把语言识别和存储规则定死
不管你最后选哪种翻译方案,语言从哪来这件事是独立的。建议按这个优先级取:
- URL 显式指定(
/en/post/1)——最利于 SEO 和分享;
- 用户表里的
lang 字段(登录用户);
- Cookie(游客手动切换过);
- 浏览器
Accept-Language 头;
- 站点默认语言。
代码大致长这样:
function detectLang(array $support, string $default = 'zh-cn'): string {
if ($l = $_GET['lang'] ?? null) return in_array($l, $support) ? $l : $default;
if (!empty($_SESSION['lang'])) return $_SESSION['lang'];
if (!empty($_COOKIE['lang'])) return $_COOKIE['lang'];
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
foreach (explode(',', $header) as $part) {
$code = strtolower(substr(trim(explode(';', $part)[0]), 0, 2));
if (in_array($code, $support)) return $code;
}
return $default;
}
注意:不要每一步都去读数据库查用户语言,在请求开头算一次存进常量或全局变量,后面所有地方复用,否则一个页面能多出几十次查询。
第二步:方案一 —— PHP 数组文件(最省事)
语言文件直接 return 一个数组,配合一个 t() 函数:
// lang/zh-cn.php
return [
'login.success' => '登录成功',
'post.delete_confirm'=> '确定删除这条帖子吗?',
];
function t(string $key, array $vars = []): string {
static $dict = null;
if ($dict === null) {
$file = __DIR__ . '/lang/' . LANG . '.php';
$dict = is_file($file) ? require $file : [];
}
$text = $dict[$key] ?? $key; // 找不到就显示 key,方便发现漏译
return $vars ? strtr($text, $vars) : $text;
}
优点是零依赖、零配置,require 一次全程可用,配合 OPcache 基本没有性能损耗。缺点是翻译人员得会改 PHP,词条一多文件就很难管。
注意:占位符用 strtr($text, [':name' => $name]),别用 sprintf。因为不同语言的语序不一样,sprintf 会把参数顺序写死,翻译成日语或阿拉伯语就翻车。
第三步:方案二 —— gettext(.po / .mo)
这是 Linux 系统级的翻译方案,生态最成熟,Poedit、Crowdin 这类工具都能直接编辑 .po 文件。
putenv('LC_ALL=zh_CN.UTF-8');
setlocale(LC_ALL, 'zh_CN.UTF-8');
bindtextdomain('myapp', __DIR__ . '/locale');
textdomain('myapp');
echo _('Login successful');
优点是有成熟的翻译工具链、支持复数形式、性能极好(.mo 是二进制哈希表,常驻内存)。
缺点也很硬:必须编译 .mo 文件,而编译通常要用 msgfmt 命令行;很多虚拟主机根本没装 gettext 扩展;setlocale 依赖系统装了对应 locale,zh_CN.UTF-8 没装就直接静默降级成英文。
注意:setlocale 是进程级设置,在 php-fpm 下每个请求都要重设一次;另外它会影响 number_format、strtoupper 等系统函数的行为,可能把你原本正常的代码搞出意外结果。
第四步:方案三 —— JSON / INI 键值 + 占位符(多数站点推荐)
结构和数组方案一样,只是把词条换成纯数据格式:
{
"login.success": "登录成功",
"cart.items": "购物车里有 {count} 件商品"
}
好处是同一份文件 PHP 和前端 JS 都能直接读——PHP 端 json_decode,浏览器端 fetch 拿同一份 JSON,前后端词条永不失同步。这是数组方案做不到的。
读取时加一层内存缓存,别每次请求都 file_get_contents:
function dict(string $lang): array {
static $cache = [];
return $cache[$lang] ??= json_decode(
file_get_contents(__DIR__ . "/lang/{$lang}.json"), true
) ?: [];
}
注意:JSON 不支持注释,别想着写 //;同时确保文件是 UTF-8 无 BOM,否则第一条词条前面会多出不可见字符,排查起来很痛苦。
第五步:方案四 —— 数据库词条表 + 后台可视化维护
如果翻译要交给运营而不是开发,就得把词条搬进数据库:
CREATE TABLE lang_strings (
id INT AUTO_INCREMENT PRIMARY KEY,
skey VARCHAR(191) NOT NULL,
lang VARCHAR(16) NOT NULL,
val TEXT,
UNIQUE KEY uk (skey, lang)
);
请求开始时一次性把当前语言的全部词条 SELECT 出来塞进内存数组,之后走和数组方案一模一样的 t() 函数。
注意:这张表必须整体缓存。如果你在模板里每输出一句话就查一次库,一个列表页轻松上百条 SQL。改动词条后记得清掉缓存(文件缓存或 Redis 都行)。
第六步:怎么选
| 方案 | 上手成本 | 性能 | 谁能维护 | 适合场景 |
|---|
| PHP 数组 | 极低 | 极好 | 开发 | 小站、语言固定两三种 |
| gettext | 中 | 极好 | 翻译工具 | 有运维能力的正规项目 |
| JSON/INI | 低 | 好 | 开发+前端 | 前后端都要用词条 |
| 数据库 | 高 | 依赖缓存 | 运营 | 词条多、要后台改 |
收尾:三个绕不过的坑
复数与语序:中文没有复数,英文有。硬拼 $n . ' items' 一定出问题。PHP 的 intl 扩展提供 MessageFormatter,支持 ICU 语法:{count, plural, one {# item} other {# items}},一次写好所有语言通用。
日期和数字:别硬编码格式。用 IntlDateFormatter、NumberFormatter,money_format 早就废弃了。
输出转义:翻译文本同样是不可信内容(尤其数据库方案),输出到 HTML 前一律 htmlspecialchars($text, ENT_QUOTES, 'UTF-8')。
小结
- 语言识别优先级:URL > 用户设置 > Cookie > Accept-Language > 默认,且只在请求开头算一次。
- 小站直接用 PHP 数组或 JSON 文件,零依赖、无性能负担;要交给运营维护再上数据库表,但必须整体缓存。
- gettext 最专业,代价是必须编译 .mo 且依赖系统 locale,共享主机上经常装不了。
- 占位符统一用
strtr 或 ICU,别用 sprintf 锁死参数顺序。
- 复数、日期、数字格式交给
intl 扩展,别自己拼字符串。