装 PHP 的时候,1Panel 会列出一大堆扩展让你勾。多数人的做法无非两种,要么全选怕漏,要么照着某篇《PHP 必装扩展推荐》勾一遍。
两种做法有同一个毛病。这份清单不是从你的项目推出来的。
漏勾的后果是线上直接 500,页面都出不来。多勾的后果是容器启动刷一串 Warning,还因为缺共享库白白拖长启动时间。
好消息是,这份清单根本不用凭经验。Composer 早就把答案写进 composer.lock 了。
先说这次的机器。PHP 是 8.4.25,容器镜像 1panel-php-fpm:8.4.25。框架 ThinkPHP 8.1.4,也就是 topthink/framework。依赖管理是 Composer 2,plugin-api-version 2.9.0。部署口径是 composer install –no-dev。1Panel 容器化,PHP-FPM 端口映射到 127.0.0.1:9000。
命令里用变量代替容器名,先在终端跑一次:
export PHP_CTN="php84"
标「实测」的来自本项目真实 composer.lock 的解析结果和容器回显。
能拿到扩展清单的来源有三个。只有一个能当判据。
1Panel 建 PHP 环境时,扩展模板默认那个叫 Default,勾了一大批。gd、intl、memcached、redis 都在里面。我当时在面板上看到的就是这个 Default 模板,勾了一大排。
但这是镜像维护者的通用偏好,跟你的项目没关系。把这三个在本项目里搜一遍:
# 图像处理
grep -rnE "imagecreatefrom|imagecopy|getimagesize" app/ # 0 命中
# 国际化
grep -rnE "Collator|NumberFormatter" app/ # 0 命中
# 内存缓存
grep -rniE "memcached" app/ # 0 命中
全项目零引用。勾了只有两个后果,容器启动刷 4 条 PHP Warning,还有因为缺 11 个共享库把启动拖长。
扫 app/ 能告诉你代码里调了哪些函数。比如搜到 bcsub(),就知道要 bcmath。
但它有个硬盲区。有些扩展不是代码调用的。
典型例子是 ctype。它是框架包在 require 里声明的平台依赖,代码里一次都没出现。你只扫代码,永远扫不出它。
规则一句话,Composer 只校验 require 段里声明的平台依赖,不管你实际有没有调用它。所以判据问的是装的时候会不会被拦,不是运行时会不会用到。这两件事差得很远,差集列在下面。
Composer 官方自带一条命令干这件事:
# 生产口径:只核对 require 段
composer check-platform-reqs –no-dev
官方文档对它的描述,原文:
The check-platform-reqs command checks that your PHP
and extensions versions match the platform requirements
of the installed packages. This can be used to verify
that a production server has all the extensions needed
to run a project after installing it for example.
Unlike update/install, this command will ignore
config.platform settings and check the real platform
packages so you can be certain you have the required
platform dependencies.
后面那句很关键。它绕开 config.platform,直接查真实环境。所以它回答的是这台机器到底够不够,而不是配置里假装够不够。
–no-dev 的官方说明只有一句。原文是 Disables checking of require-dev packages requirements.
不过我想要更细的,每个扩展到底是哪个包要求的。那就直接解析 lock:
# 在项目根目录执行
php -r '
$l = json_decode(file_get_contents("composer.lock"), true);
foreach (["packages", "packages-dev"] as $sect) {
$m = [];
foreach ($l[$sect] as $p) {
foreach (array_keys($p["require"] ?? []) as $k) {
if (str_starts_with($k, "ext-")) { $m[$k][] = $p["name"]; }
}
}
echo "=== $sect ===\\n";
ksort($m);
foreach ($m as $ext => $who) { printf(" %-14s <- %s\\n", $ext, implode(", ", $who)); }
echo "\\n";
}'
实测输出(本项目真实 composer.lock,2026 年 9 月 19 日复核):
packages : 15
packages-dev : 4
=== packages 段 ===
ext-ctype <- topthink/framework
ext-fileinfo <- league/flysystem-local, league/mime-type-detection
ext-json <- topthink/framework, topthink/think-orm
ext-mbstring <- topthink/framework
ext-pdo <- topthink/think-orm
=== packages-dev 段 ===
ext-iconv <- symfony/polyfill-mbstring
生产段的答案就是上面那 5 行。15 个生产包里只有 5 条扩展声明,这就是必需清单的全部内容。
对着上面那份输出,第一反应通常是 ext-iconv 也在表里,那也得装。
不对,它在 packages-dev 段。
规则是这样。composer install 会装 packages 加 packages-dev,两个段都校验。composer install –no-dev 只装 packages,也只校验 packages。
所以 ext-iconv 在开发机上会被校验,生产部署时不会。它是 symfony/polyfill-mbstring 带进来的,而那个包本身落在开发段里。
这一步我实打实地判错过一次。早期版本的手册把 ext-iconv 归成了必需,直到对真实 lock 逐段解析才纠正过来。
区分点只有一个,看它在哪个段,不看它有没有出现在 lock 里。
补一句。ext-iconv 和 ext-ctype 在官方镜像里都是默认编译项,实测都在位。所以这是个判断错了但没出事的例子。没出事,不代表判断是对的。
再往深一层,你还会撞见这种情况。ext-zip 确实出现在 lock 里,但不在任何 require 段:
league/flysystem 3.36.0
require = [league/flysystem-local, league/mime-type-detection, php]
require-dev = […, ext-zip, …] ← 这是这个包自己的开发依赖
这是 league/flysystem 这个包自己的 require-dev,不是项目的。
Composer 不管它。只有根项目的 require-dev 才会被安装和校验。依赖包自己的 require-dev 是给它自己跑测试用的,永远不会装。
所以 ext-zip 不是生产必需项。尽管这个项目最终确实需要它,但原因完全是另一条路。
这里有个容易被数字绕晕的地方。查 composer.lock 得到 5 项,实际部署要保证 8 项。差额从哪来?
答案是,Composer 会拦的和代码运行必须的是两回事。
Composer 会拦的,也就是 lock 校验的,有 5 项。ctype、fileinfo、json、mbstring、pdo,装依赖的时候会报错。
Composer 不拦但代码必须的有 bcmath,首页接口的 bcsub() 缺了直接 500。还有 curl 和 openssl,支付模块的 RSA 签名、AES 解密缺了,支付全线失败。
还有一个工具层,部署时才发现,也就是 zip 或 unzip。composer install 要解压 dist 包,缺了两条路都不通。
后两类光看 composer.lock 是看不出来的,得配合代码扫描和一次真实部署。
举个工具层的例子。ext-zip 不在必需清单里,但部署时绕不过去。Composer 解压 .zip 格式的 dist 包需要解包能力。它走 ZipArchive 类,或者容器里的 unzip、7z 命令。这两条路都不在 require 段里声明。
这条只能靠实测发现,composer install 会直接给你:
The zip extension and unzip/7z commands are both missing, skipping.
所以完整清单是三层,顺序不能反。第一层是必需,来源是 composer.lock 的 packages[].require,由 Composer 拦你。第二层是工具层,来源是部署实测,解包、上传这类,跑起来才会发现。第三层是可选,来源是代码扫描加性能考虑,你自己决定。
这是 PHP 容器部署里最大的一个坑。扩展的 .so 文件在,但它依赖的共享库不在基础镜像里,面板上显示已启用,实际上一次都没加载。我一开始以为面板显示已启用就是加载好了。
核验命令,关键是 ^(…)$ 精确匹配:
# 逐项核对,精确匹配
docker exec -i "$PHP_CTN" php -m | grep -iE '^(pdo_mysql|mbstring|openssl|curl|fileinfo|bcmath|iconv|ctype|json|opcache|zip)$' | sort
# 同时看有没有加载告警
docker exec -i "$PHP_CTN" php -m 2>&1 | grep -i "Unable to load" || echo "扩展零告警"
为什么要加 ^(…)$?因为宽松匹配时 opcache 会命中 Zend OPcache 这种干扰行,让你误判成已加载。本项目早期就是这么漏掉 opcache 的。
实测回显(本项目 PHP 8.4.25):
bcmath
ctype
curl
fileinfo
iconv
json
mbstring
openssl
pdo_mysql
zip
→ 第二条命令:无输出(零告警)
必需 5 项,加上备位的 iconv 和建议项 zip,全部在位。
判据收一下。生产上必须有哪些扩展,跑 composer check-platform-reqs –no-dev,看是不是全部 OK。每个扩展是谁要求的,解析 lock 的 packages[].require,只认这个段。某个 ext-* 该不该装,看它在 packages 还是 packages-dev,只有 packages 段是硬要求。
上游包的 require-dev 要不要管,看它挂在哪个包上。挂在自己包上的,永远不装。面板勾了但不确定有没有生效,用 php -m 加 grep -iE ‘^(…)$’,精确匹配列出,有告警输出才是真缺。该不该勾某个可选扩展,grep 代码里的实际调用点,零命中就不勾。


