学完这篇,你能给一个纯 PHP 项目加上多语言支持:界面文案、日期数字格式、数据库内容三层都能切换,并且知道哪些坑容易踩。
第一步:先分清 i18n 和 l10n,别一上来就写代码
国际化(i18n)是让代码具备多语言能力:文案抽出来、格式不硬编码。本地化(l10n)是针对某个地区准备具体内容:一份 en_US 的语言包、一套英文文案。先做 i18n,再补 l10n,顺序反了会反复重构。
方案上分两档:小项目(几十个页面、几十个文案)用 PHP 数组语言包最省事;文案上千条、要接翻译团队,再上 gettext(.po/.mo)。下面以数组方案为主线,因为不依赖扩展、改完即时生效。
第二步:建目录与语言包
/lang/zh_CN.php
/lang/en_US.php
/lang/ja_JP.php
zh_CN.php 内容:
<?php
return [
'nav.home' => '首页',
'welcome' => '欢迎,{name}',
'posts.count' => '共 {count} 篇帖子',
];
en_US.php 保持同样的键,值换成英文。键名用点分层级,不要拿中文当键——中文一改,所有引用都得跟着改。
注意:语言包只放文案,不要在里面写 HTML 标签和逻辑。{name} 这类占位符统一用花括号,别混用 %s 和 {{}}。
第三步:写一个 t() 函数,带回退和占位符替换
function t(string $key, array $params = []): string
{
static $dict = null, $loaded = null;
$lang = current_lang();
if ($dict === null || $loaded !== $lang) {
$file = __DIR__ . '/lang/' . $lang . '.php';
$dict = is_file($file) ? require $file : [];
$loaded = $lang;
}
$text = $dict[$key] ?? $key; // 缺失时回退成键名,方便一眼看出漏翻
foreach ($params as $k => $v) {
$text = str_replace('{' . $k . '}', (string)$v, $text);
}
return $text;
}
模板里这样用:<?= h(t('welcome', ['name' => $user['name']])) ?>,其中 h() 是 htmlspecialchars 的封装。翻译结果和用户数据一样不可信,写进 HTML 前必须转义。
第四步:确定当前语言(cookie → URL → Accept-Language)
function current_lang(): string
{
static $lang = null;
if ($lang !== null) return $lang;
$supported = ['zh_CN', 'en_US', 'ja_JP'];
$candidate = $_COOKIE['lang'] ?? $_GET['lang'] ?? '';
if (!in_array($candidate, $supported, true)) {
$candidate = 'zh_CN';
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
foreach (explode(',', $header) as $part) {
$tag = strtolower(trim(explode(';', $part)[0]));
$map = ['zh' => 'zh_CN', 'en' => 'en_US', 'ja' => 'ja_JP'];
$short = substr($tag, 0, 2);
if (isset($map[$short])) { $candidate = $map[$short]; break; }
}
}
$lang = $candidate;
return $lang;
}
优先级建议:用户显式选择(cookie)> URL 参数 > 浏览器头 > 站点默认。
注意:$_GET['lang'] 一定要走白名单校验再使用。它会被拼进文件路径 lang/xxx.php,不校验就等于把任意文件包含漏洞送出去。绝不要用 include $_GET['lang'] . '.php' 这种写法。
第五步:数据库内容的本地化
界面文案好办,帖子标题正文才是大头。两种做法:
- 主表 + 翻译附表(推荐):
posts 存原语言内容,post_i18n(post_id, lang, title, content) 存译文。查询时左连接,缺译文自动回落原文:
SELECT p.id, COALESCE(t.title, p.title) AS title
FROM posts p
LEFT JOIN post_i18n t ON t.post_id = p.id AND t.lang = ?
WHERE p.id = ?
- 每行加
lang 字段:适合中英文完全分站、内容不互通的场景,但跨语言搜索会很别扭。
无论哪种,都要给 post_i18n 建 (post_id, lang) 唯一索引,避免重复译文。
注意:如果站点有整页缓存或 Redis 缓存,缓存键必须带语言,例如 post:12:zh_CN。否则中文用户刷新一次,英文用户就看到中文页面了。
第六步:日期、数字、货币格式化
别手写 date('Y年m月d日'),英文站会变成 2025年03月08日 混在英文里。有 intl 扩展就直接用:
if (class_exists('IntlDateFormatter')) {
$fmt = new IntlDateFormatter(current_lang(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
echo $fmt->format($timestamp);
} else {
echo date('Y-m-d', $timestamp); // 降级:无 intl 时用中性格式
}
货币同理,用 NumberFormatter 的 CURRENCY 样式;没有 intl 就退回 number_format() 加手写符号。写个 format_money() 统一出口,以后要换实现只改一处。
第七步:别忘了 SEO 和复数
多语言站要在 <head> 里声明对应关系,告诉搜索引擎这是同一页的不同语言版本:
<link rel="alternate" hreflang="zh-CN" href="https://example.com/post/12">
<link rel="alternate" hreflang="en" href="https://example.com/post/12?lang=en_US">
<link rel="alternate" hreflang="x-default" href="https://example.com/post/12">
复数形式:中文没有单复数,英文有 1 post / 2 posts。简单场景用两个键 posts.count_one 和 posts.count_other,代码里按 $n === 1 选;复杂语言(俄语、阿拉伯语有多档)就上 MessageFormatter 的 ICU 语法。
小结
- 先 i18n 后 l10n:抽文案、去硬编码是前提。
- 小项目用 PHP 数组语言包,键名点分层级,缺翻译回退成键名便于排查。
t() 统一出口,占位符用 {name},输出前必须 htmlspecialchars。
- 语言来源走白名单校验,绝不把未校验的语言参数拼进 include 路径。
- 数据库内容用「主表 +
post_i18n 附表 + COALESCE 回落」,并建 (post_id, lang) 索引。
- 缓存键、日期、数字、货币格式化全部要跟着语言走。
- 多语言页面对外补
hreflang,对内补复数规则。