一、先弄清 .user.ini 到底是什么
PHP 的 .user.ini 是自 PHP 5.3 起引入的"用户级配置"机制,作用对象是 CGI / FastCGI / FPM 三种运行方式。它和 php.ini 的关系不是"覆盖",而是"分成两层":php.ini 属于全局层,由管理员维护;.user.ini 属于目录层,跟着代码走,可以放进每个项目目录。
它的最大特点是可分级、可继承、可动态生效。你在 /www/wwwroot/site/ 放一个 .user.ini,这个目录及其所有子目录都继承它的设置;再在子目录放一个,只覆盖该子目录。更关键的是 PHP-FPM 会周期性重新扫描这些文件,改完之后通常在 user_ini.cache_ttl(默认 300 秒)内自动生效,不需要重启服务。这一点在生产环境非常有用——改 upload_max_filesize 不用动服务进程。
但这也意味着一个反直觉的事实:有些配置无论你怎么改 .user.ini 都不会生效。凡是标记为 PHP_INI_SYSTEM 的项,只有 php.ini 或 FPM 池配置能改,.user.ini 改了会被直接忽略,而且不报错——它只是安静地不起作用。这是排查时最容易卡住的地方。
二、哪些能改,哪些改了白改
判断方法很简单:查官方文档里每个配置项的"Changeable"一栏。标 PHP_INI_ALL 或 PHP_INI_PERDIR 的,可以在 .user.ini(或 ini_set)里改;标 PHP_INI_SYSTEM 的只能在 php.ini / FPM 池里改。
实际项目里经常需要在目录级调整的、可以用 .user.ini 的项:
upload_max_filesize、post_max_size、max_file_uploads(做上传站必改)memory_limit、max_execution_time(某个导出脚本需要更长时间)display_errors、error_reporting、log_errors(开发目录单独打开报错)session.save_path、session.gc_maxlifetime(同一台机器跑多个站点时隔离会话)auto_prepend_file、include_path(给某个子站注入统一引导文件)date.timezone、default_charset
不能改的典型项(写了也无效,需要改 php.ini 或 FPM 池):
open_basedir(安全边界必须由管理员定,属 PERDIR 但 FPM 池里更稳)disable_functions、disable_classesexpose_php、allow_url_fopen、allow_url_includeextension、zend_extension(加载扩展只能在 php.ini)opcache.*的绝大多数项(尤其是opcache.enable、validate_timestamps)
第一条排查动作因此固定为:先确认要改的项是不是 PERDIR/ALL 级别。确认之后再谈"为什么不生效",否则会在错误的方向上耗一整天。
三、语法差异:别把 php.ini 的写法照搬
.user.ini 的语法看起来和 php.ini 一样,都是 key = value,实际有三处差异经常让人踩坑。
差异一:不能用 section 和注释符号的区别。传统 php.ini 用 [PHP]、[Date] 这样的段名,.user.ini 里段名会被忽略(但不会报错),建议直接不写。注释方面 ; 是标准写法,但很多编辑器里顺手用 #,PHP 从 5.4 起两种都能认,不过为了兼容老版本还是统一用 ;。
差异二:值不需要引号,也不支持表达式。写 post_max_size = 64M 可以,写 post_max_size = "64M" 也勉强能认,但写 post_max_size = 32M * 2 会直接解析失败。遇到过有人写 memory_limit = 512M; 生产,分号后面的注释和值在同一行,PHP 会把整行当作非法行跳过——这个文件解析失败是静默的,整份文件都不生效。
差异三:区分大小写。配置项名要写对,upload_max_filesize 写成 Upload_Max_FileSize 不会报错,只会被忽略。
最后一条最为关键,也是最难排查的:只要文件里有一行语法错误,整个文件的所有配置都不生效。不是"跳过那一行",是整份作废。所以改完 .user.ini 之后必须验证,不能靠"我改了应该生效了"来假设。
四、生效验证:三种手段,从粗到细
最直接的办法是在目录里放一个临时 PHP 文件打印配置:
<?php
header('Content-Type: text/plain');
foreach (['upload_max_filesize','post_max_size','memory_limit',
'session.save_path','display_errors','open_basedir'] as $k) {
printf("%-22s local=%s master=%s\n", $k, ini_get($k), ini_get_all(null, false)[$k]['global_value'] ?? '');
}
echo "loaded_ini: ", php_ini_loaded_file(), "\n";
echo "scanned: ", php_ini_scanned_files(), "\n";
看 local 和 master 两列的值:如果 local 变了你想要的值,说明生效;如果两列都一样且都没变,说明 .user.ini 没被读到。更省事的写法是直接 php_sapi_name() 加 php_ini_scanned_files(),确认当前跑的是 FPM 而不是别的 SAPI。
第二种手段是 phpinfo() 里的 "Scan this dir for additional .ini files" 一项——注意这是 PHP_INI_SCAN_DIR 扫描的 ini 文件,不是 .user.ini,很多人在这里看错了。要确认 .user.ini 是否被读,看 phpinfo 里对应配置项右侧的 "Local Value" 列是否与 Master 不同。
第三种手段是命令行验证,适合脚本化检查:
php -i | grep -E 'upload_max_filesize|Loaded Configuration'
但要注意:CLI SAPI 完全不读 .user.ini。你用 php -i 看到的永远是 php.ini 的值,这一点是新手最常见的误判来源——在终端里试了半天没变化,就断定"配置写错了",其实文件本身是对的,只是 CLI 不认。
五、Nginx 与 Apache 下的差异
在 Apache 用 mod_php 或 mod_php-fpm 时,.user.ini 依赖 AllowOverride 设置。如果 AllowOverride None,.htaccess 和 .user.ini 都不会被读取。所以 Apache 场景下第一步是确认目录配置。
在 Nginx + PHP-FPM 下,.user.ini 的读取由 user_ini.filename 和 user_ini.cache_ttl 两个参数控制,在 php.ini 或 FPM 池里配置:
; php.ini
user_ini.filename = .user.ini
user_ini.cache_ttl = 300
如果 user_ini.filename 留空,整个机制关闭。cache_ttl 太大会让你改完等很久才生效(默认 300 秒),排查阶段可以临时改成 5 秒,验证完再调回去。
更隐蔽的一个坑:Nginx 默认不会把以点开头的文件作为静态资源暴露,但也不会拦截它的请求返回 403。你需要主动加一条规则,禁止外部访问 .user.ini:
location ~ /\.user\.ini$ {
deny all;
return 403;
}
# 另外把常见敏感文件一起封掉
location ~ /\.(ht|git|env|user\.ini) {
deny all;
}
不加这条规则,https://站点/.user.ini 会被当成静态文件下载,虽然内容本身不是密钥,但暴露了你的目录结构和配置习惯,属于不必要的信息泄露。这一点在很多现成的 LNMP 一键包里默认没有,值得检查一遍。
六、继承规则的三个陷阱
陷阱一:子目录只覆盖同名项,不重置其它项。父目录设了 memory_limit = 512M,子目录的 .user.ini 只写 upload_max_filesize = 64M,那么子目录依然是 512M 内存。这符合直觉,但很多人以为子目录的配置是"全新的"。
陷阱二:不存在的目录中断继承链。PHP 会从当前脚本所在目录逐级向上扫描到 DOCUMENT_ROOT,或者到 open_basedir 的边界为止。如果你的站点根目录设置得比实际路径浅,或者 open_basedir 卡在中间,扫描会在那里停住,放在更上层的 .user.ini 就读不到。这是"同一个文件中 A 目录生效 B 目录不生效"的最常见原因。
陷阱三:.user.ini 放在站点根目录之外不生效。比如你想给整个 /www/wwwroot/ 下的所有站点统一设 memory_limit,把文件放在 /www/ 是没用的,扫描在 DOCUMENT_ROOT 就停了。统一配置应该走 FPM 池的 php_admin_value。
七、最常见的一次真实排查
现象:上传 20MB 的视频报错,站点根目录已经有 .user.ini 写着 upload_max_filesize = 64M 和 post_max_size = 64M,但依然失败。
按顺序查下来,问题往往出在这几层:
- 文件放在
/www/wwwroot/site/.user.ini,但上传接口实际走的是/www/wwwroot/site/api/目录,而api是一个软链接指向别处。FPM 解析真实路径后,扫描起点变成了软链接目标目录,继承链断了。 - 只在
.user.ini里改了upload_max_filesize,忘了post_max_size。这两个必须同时改,后者必须大于前者,否则 20MB 的文件在 POST 阶段就被丢掉,$_FILES是空的,错误信息啥也不给。 - Nginx 的
client_max_body_size还是默认的 1M,请求在到达 PHP 之前就被 413 掉了。 - FPM 池里写了
php_admin_value[upload_max_filesize] = 8M。php_admin_value的优先级高于.user.ini,而且不可被后者覆盖——这是最狠的一层,改了目录配置永远不会生效。
四层里任意一层没处理,症状都是"上传失败",但错误信息完全不同。按"Nginx → php_admin_value → .user.ini 语法/位置 → 软链接与 open_basedir"的顺序查,基本一次定位。
八、安全:为什么它会被攻击者盯上
.user.ini 里 auto_prepend_file 这一项是历史上有名的攻击载体。攻击者如果能往某个目录写文件(比如通过文件上传漏洞传一个 .user.ini),就可以让该目录下所有 PHP 脚本在执行前自动包含一个恶意文件,从而获得执行权限。这种手法常被归在"文件包含 / LFI"家族里。
防护有两条硬措施:
- 在 FPM 池里设
php_admin_value[user_ini.filename] =(留空)彻底关闭该机制。代价是你失去了目录级配置能力,需要改用池或php_value统一管理。多数生产环境应该这么做。 - 如果必须保留,至少把
auto_prepend_file和auto_append_file通过php_admin_value锁死(设成空或固定的引导文件),并确保上传目录的 Nginx location 里location ~ \.php$ { return 403; }完全禁止执行。
顺便说,这也是为什么"上传目录不能执行 PHP"要作为一条铁律——不管你有没有用 .user.ini,只要上传目录能跑 PHP,攻击者总有办法利用。
九、一份可直接用的配置模板
给一般的内容站点,.user.ini 放这几行就够了,既解决上传限制,又保证开发期能看报错:
; 站点级配置,改完 5 分钟内自动生效
upload_max_filesize = 64M
post_max_size = 64M
max_file_uploads = 20
memory_limit = 256M
max_execution_time = 60
date.timezone = Asia/Shanghai
default_charset = UTF-8
session.save_path = /www/wwwroot/site/sess
; 生产环境关闭报错显示,仅记录日志
display_errors = Off
log_errors = On
注意 session.save_path 指向的目录必须真实存在且 FPM 运行用户可写,否则会话会静默失效——这也是"登录后立刻掉登录"的一类原因。目录权限建议 700,属主为 FPM 用户。
mkdir -p /www/wwwroot/site/sess
chown www:www /www/wwwroot/site/sess
chmod 700 /www/wwwroot/site/sess
十、小结
.user.ini 的价值在于"把配置跟代码放在一起",让每个站点或子目录拥有自己的上传大小、内存和执行时间。它出问题的形态高度集中在三处:改的项其实是 PHP_INI_SYSTEM 级别、文件里有一行语法错误导致整份作废、以及被 FPM 池的 php_admin_value 或 Nginx 的 client_max_body_size 抢先卡住。排查时永远先从"这个项能不能在这层改"问起,再看"这层是不是真被读到了",最后才怀疑内容本身。