前言
先说一个容易让人怀疑人生的现象:你在服务器上明明export APP_ENV=production,在 shell 里echo $APP_ENV也有输出,但 PHP 里$_ENV就是一个空数组,getenv('APP_ENV')返回false。于是你开始怀疑 PHP 版本、怀疑 Docker、怀疑自己是不是写错了大小写。
环境变量的读取机制跟 PHP 版本没有关系——标题里的 PHP 8.5 只是运行环境,本文讲的getenv()/$_ENV/$_SERVER三者的关系、variables_order的作用、PHP-FPM 的clear_env行为,从 PHP 5.4 时代到现在都没变过。所以本文不会去硬凑 8.5 的语法特性,只讲真正会把人卡住的那几个点。
第二个高频症状出现在上线之后:本地getenv()读得到配置,部署到 FPM 就读取不到;或者反过来,在某个请求里putenv()设了个值,下一个不相干的请求竟然也读到了——后者是共享进程池的典型后果,属于安全问题,不是玄学。
本文按「三种读法的区别 → 命令行与 FPM 的差异 → .env 文件的正确姿势 → 安全边界」讲,最后给一个可以直接跑的 .env 加载器。
一、三种读法的区别
| 读法 | 数据来源 | 受variables_order影响 | 典型坑 |
|---|---|---|---|
getenv('KEY') | 进程真实环境变量 | 否 | 值不存在时返回false,不是null |
$_ENV['KEY'] | PHP 从环境变量填充的数组 | 是(需要E) | 默认配置下是空数组 |
$_SERVER['KEY'] | 服务器/请求相关的键值集合 | 是(需要S) | 里面混着 HTTP 头、路径等,语义杂 |
最关键的差异是第一行和第二行:getenv()不受variables_order影响,$_ENV受。php.ini 的默认值是:
; 默认值:没有 E,所以 $_ENV 永远是空的 variables_order = "GPCS" ; 改成这样 $_ENV 才有内容 ; variables_order = "EGPCS"G= GET、P= POST、C= COOKIE、S= SERVER、E= ENV。很多人第一次查这个问题,看到的答案都是"改variables_order",改完$_ENV确实有值了,但要注意两件事:一是这会略微增加每个请求的开销(要填充更多超全局数组),二是CLI 和 FPM 用的是两份不同的 ini 文件,你改的那份不一定是线上生效的那份。
验证方法很直接:
# CLI 用的是哪份 ini、当前值是什么 php -i | grep -i "variables_order" # 只有运行在 FPM 下的脚本才能反映线上的值 php -r 'echo ini_get("variables_order"), PHP_EOL;'要在 Web 环境里确认 FPM 的真实配置,就写一个临时脚本输出phpinfo()或ini_get(),通过浏览器访问它,而不是在服务器上敲php -i——后者查的是 CLI 的配置。
二、CLI 与 FPM 的差异:clear_env是关键
命令行下,子进程继承 shell 的环境变量,所以你export之后getenv()一定能读到。PHP-FPM 不一样:它是常驻的进程池,出于安全考虑,默认会清空工作进程继承来的环境变量。这条配置叫clear_env,默认值是yes。
这就解释了两个现象:一是export的变量在 FPM 里读不到;二是 .env 文件加载库(或者其他方式)设置的环境变量在本地环境生效、在服务器上不生效。
给 FPM 传环境变量的正确做法是在池配置(如www.conf)里写env[...],并关闭clear_env:
; /etc/php/8.5/fpm/pool.d/www.conf [www] ; 默认是 yes,会清掉继承的环境变量 clear_env = no ; 需要显式传给 PHP 的变量 env[APP_ENV] = production env[APP_DEBUG] = 0 env[DB_HOST] = 127.0.0.1改完必须平滑重启FPM 才生效(reload就够,不必restart导致请求中断)。
另一种常见做法是在 Nginx 侧传:
location ~ \.php$ { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param APP_ENV production; include fastcgi_params; }这种方式传进来的值会出现在$_SERVER['APP_ENV']里,读取不依赖variables_order,但它本质是"请求参数"——任何能改 Nginx 配置或构造 FastCGI 请求的环节都可能覆盖它,而且getenv('APP_ENV')是否可见取决于 SAPI 的具体行为。所以更推荐用env[]放真正需要getenv()读的配置,Nginx 那边只传协议相关的东西。
三、.env 文件:最好用的还是自己解析
本地开发时没人愿意天天配 FPM,通用做法是把配置写进项目根目录的.env,由应用在启动时加载进环境。PHP 没有内置的 .env 解析器,社区库(如vlucas/phpdotenv)很成熟,但它的接口会随大版本变化,这里给一个三十行、你能完全看懂的实现,顺便把解析规则讲清楚:
<?php declare(strict_types=1); // PHP 8.0+ final class Env { /** * 读取并加载 .env,不覆盖已经存在的真实环境变量(真实环境优先级更高) */ public static function load(string $file, bool $override = false): void { if (!is_readable($file)) { return; } foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#')) { continue; // 空行与注释 } if (!str_contains($line, '=')) { continue; // 非法行直接跳过,不要抛异常暴露内容 } [$key, $value] = explode('=', $line, 2); $key = trim($key); $value = trim($value); // 去掉包裹的引号(单引号内不解析转义,双引号内解析) if (strlen($value) >= 2) { $first = $value[0]; $last = $value[strlen($value) - 1]; if ($first === $last && ($first === '"' || $first === "'")) { $value = substr($value, 1, -1); if ($first === '"') { $value = stripcslashes($value); } } } if (!$override && getenv($key) !== false) { continue; // 已有真实环境变量,不覆盖 } putenv("{$key}={$value}"); // 让 getenv() 能读到 $_ENV[$key] = $value; // 让 $_ENV 能读到 $_SERVER[$key] = $value; // 兼容只读 $_SERVER 的代码 } } public static function get(string $key, ?string $default = null): ?string { $v = getenv($key); return $v === false ? $default : $v; } }注意最后三行:只有同时写putenv()、$_ENV、$_SERVER三个地方,才能保证不同风格的代码都读得到。putenv()只改了环境,$_ENV在variables_order不含E的机器上依然不受影响,而现实中项目里两种读法通常都有。
四、完整可运行示例
下面这段需要PHP 8.0+(用到str_starts_with())。把上面的Env类一起保存成env.php直接运行,它会生成一个临时 .env、加载它、演示优先级与引号解析。
<?php declare(strict_types=1); // PHP 8.0+ (Env 类定义见上文,此处省略) $file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'p09.env'; // 注意:不要在值后面写行内注释,解析器会把它当成值的一部分 file_put_contents($file, <<<'ENV' # 应用配置 APP_ENV=local APP_NAME="My App" APP_MOTTO='keep it simple' EMPTY_VALUE= ENV); putenv('APP_ENV=production'); // 模拟"真实环境已经设过值" $_ENV['APP_ENV'] = 'production'; Env::load($file); // 默认不覆盖已存在的真实环境变量 echo 'APP_ENV = ', Env::get('APP_ENV'), PHP_EOL; // 应该是 production echo 'APP_NAME = ', Env::get('APP_NAME'), PHP_EOL; // My App echo 'APP_MOTTO= ', Env::get('APP_MOTTO'), PHP_EOL; // keep it simple echo 'MISSING = ', var_export(Env::get('MISSING'), true), PHP_EOL; // NULL echo 'MISSING2 = ', var_export(Env::get('MISSING2', 'fallback'), true), PHP_EOL; echo 'EMPTY = ', var_export(Env::get('EMPTY_VALUE'), true), PHP_EOL; // 空字符串 echo '$_ENV APP_NAME = ', $_ENV['APP_NAME'] ?? '(未设置)', PHP_EOL;预期输出:
APP_ENV = production APP_NAME = My App APP_MOTTO= keep it simple MISSING = NULL MISSING2 = 'fallback' EMPTY = '' $_ENV APP_NAME = My App把Env::load($file)改成Env::load($file, override: true),APP_ENV就会变成local——这正是"谁优先"这个设计决策在代码里的位置:真实环境变量优先,.env 只做兜底,这样同一份代码放进容器不用改一个字。
常见坑点
坑 1:$_ENV是空的,就以为系统没设环境变量。❌ 花几个小时查系统配置、重装 PHP。 ✅ 先跑php -r 'var_dump(getenv("APP_ENV"), $_ENV);'对比:getenv()有值而$_ENV为空,就是variables_order缺E。
坑 2:只加载 .env 不设putenv()。❌ 只往$_ENV里塞值,项目里用getenv()的那部分代码照样读到false,表现为"一部分配置生效、一部分不生效"。 ✅putenv()、$_ENV、$_SERVER三处一起写,或者全项目统一只用一种读法。
坑 3:忘了 FPM 的clear_env = yes。❌ 在 supervisor/systemd 里Environment=APP_ENV=production,FPM 启动后工作进程的环境被清空,读不到。 ✅ 池配置里设clear_env = no并显式env[APP_ENV] = production,改完 reload FPM。
坑 4:在请求里用putenv()动态改配置。❌ FPM 的工作进程是常驻的,同一个进程会继续处理后面的请求。一个请求putenv('APP_DEBUG=1'),之后的请求就可能一直带着这个值,表现为"偶发地调试信息泄漏",极难复现。 ✅putenv()只在应用启动阶段(引导、加载 .env)使用,请求处理中不要改环境。
坑 5:.env放在 Web 根目录且没有任何拦截。❌ 直接访问站点根目录下的/.env就能把数据库密码原样拿到——这是扫描器最先尝试的路径之一。 ✅.env放在 Web 根目录之外;实在要在根目录,务必在 Nginx 里拦掉(注意~与\.的转义):
location ~ /\.(env|git|svn) { deny all; return 404; }坑 6:把.env提交进了版本库。❌ 密钥进了 Git 历史,删掉文件也没用,历史里还在;协作时人人拿到生产密钥。 ✅.env写进.gitignore,仓库里只保留一份.env.example,密钥走密钥管理服务或部署环境注入。
坑 7:值里带了引号或空格,解析出来多一个空格。❌ 写APP_ENV = production却用explode('=', $line)后不trim(),得到"production ",判断=== 'production'永远为假,行为诡异。 ✅ 键和值都trim(),支持引号包裹并去掉引号;注释只在行首识别,不要做"行内注释"的模糊切分(值里真的可能有#)。
坑 8:以为 CLI 和 FPM 读的是同一份配置。❌ 在服务器上敲php -i看到variables_order有E,就认为线上下载的接口也一样——CLI 有独立的php.ini(通常还有独立的conf.d)。 ✅ 需要确认 FPM 的真实配置时,用 Web 方式跑一个临时脚本输出ini_get(),用完立即删除。
总结
| 关注点 | 结论 |
|---|---|
| 版本相关性 | 环境变量机制与 PHP 版本无关,8.5 也没有改变读法 |
getenv() | 读进程真实环境,不受variables_order影响,取不到返回false |
$_ENV | 需要variables_order含E,默认配置下为空数组 |
$_SERVER | 含请求相关键值,Nginx 的fastcgi_param传进来也落在这里 |
| FPM 传值 | 池配置env[...]+clear_env = no,改完 reload |
| .env 加载 | 键值都 trim,处理引号,同时写putenv/$_ENV/$_SERVER |
| 优先级 | 真实环境变量优先,.env 兜底,容器与本地共用一份代码 |
| 安全 | .env 不进 Web 根目录、不进 Git;请求处理中不要putenv() |
环境变量"读不到"这件事,九成情况下不是 PHP 的问题,而是读法选错了($_ENV与getenv()的来源不同)或者值根本没传进那个进程(FPM 的clear_env把继承的环境清掉了)。先分清这两种情况,再去改配置,比盲目重装要快得多。