PHP 8.6:close 阶段失败时,fclose() 与 file_put_contents() 不再返回成功
PHP 8.6 的 BC 变更清单里有一条容易被跳过:fclose()、file_put_contents() 和 copy() 现在在 flush 或关闭 stream 失败时返回 false。此前这类失败被静默忽略,调用方拿到的是 true 或者写入字节数。RC5 定在 2026-11-05,GA 2026-11-19。
参考链接:
- 迁移文档:https://www.php.net/manual/en/migration86.new-features.php
- 文档追踪 issue:https://github.com/php/doc-en/issues/5901
file_put_contents()手册:https://www.php.net/manual/en/function.file-put-contents.php
失败点位于 write() 之后
file_put_contents() 的返回值一直是写入字节数,失败返回 false。问题在于“失败”的判定位置:数据先写进页缓存,真正的落盘和元数据更新发生在 flush / close 阶段。在 NFS 或其他网络文件系统上,EDQUOT、ENOSPC、EIO 常常在 write() 已经返回成功之后才由 close() 抛出。本地磁盘配额结算、延迟分配的文件系统也是同样的时序。
于是代码看到的是:字节数对得上,函数没报错,文件实际是截断的或者干脆没落地。
只读挂载多数在 fopen() 阶段就失败了,不在这条改动的覆盖范围内。它管的是“打开成功、写也成功、关闭失败”这一类。
以前为什么是静默的
PHP 的 stream 层在关闭时执行的 flush 结果没有向上传播。fclose() 只看资源是不是有效、关闭流程有没有走完,flush 阶段底层返回的错误码被丢掉了。file_put_contents() 则用 php_stream_write() 的返回值累加字节数,close 的返回值不参与最终结果。所以错误不是没发生,是没传到 PHP 用户态。
升级后需要改的写法
file_put_contents():保留原来的字节数比对,再加 false 判断。
$data = json_encode($payload, JSON_UNESCAPED_UNICODE);
$bytes = file_put_contents($path, $data);
if ($bytes === false) {
throw new RuntimeException("write failed: {$path}");
}
if ($bytes !== strlen($data)) {
throw new RuntimeException("short write: {$bytes}/" . strlen($data));
}
注意 === false,不能用 !$bytes——写入 0 字节是合法结果。
copy():以前 close 阶段失败返回 true,现在返回 false,调用点如果是 copy($a, $b) or die() 这类写法,行为会变。
if (!copy($src, $dst)) {
throw new RuntimeException("copy failed: {$src} -> {$dst}");
}
fclose():手动管理句柄的路径要把返回值纳入判断,尤其是先 fwrite() 再 fclose() 的写法。
$fh = fopen($tmp, 'wb');
if ($fh === false) {
throw new RuntimeException("open failed: {$tmp}");
}
if (fwrite($fh, $chunk) === false) {
fclose($fh);
throw new RuntimeException("write failed: {$tmp}");
}
if (!fclose($fh)) {
throw new RuntimeException("close failed: {$tmp}");
}
rename($tmp, $final); // 只在 close 成功之后
临时文件 + rename() 的原子写模式尤其要检查这一条:rename() 之前必须确认 fclose() 没有返回 false,否则移动的是一个内容不完整的文件。
fsync 与校验点的关系
PHP 没有把 fsync() 封装进 file_put_contents()。要主动把数据推到存储,得自己走 fopen + fwrite + fflush + fsync + fclose:
$fh = fopen($path, 'wb');
fwrite($fh, $data);
fflush($fh);
if (!fsync($fh)) {
fclose($fh);
throw new RuntimeException("fsync failed: {$path}");
}
if (!fclose($fh)) {
throw new RuntimeException("close failed: {$path}");
}
两者的分工不同:fsync() 是主动校验点,把 ENOSPC / EIO 提前暴露在一个有返回值的调用上;fclose() 的 false 是最后一道兜底,覆盖那些直到关闭才结算的错误。只有 fsync() 不够——配额和网络文件系统的部分错误仍然在 close 时才出现;只有 fclose() 也不够——你拿不到明确的同步点。
对可靠性要求高的写入,再补一层读回校验(长度或 hash),尤其是在 NFS 上。
同批 BC break 还有 by-reference foreach、array_intersect() 的字符串转换时机、trim() 默认字符集加入 \f 等,与本文主题无关,升级时单独过一遍清单即可。