
前言换个服务器/升个 PHP图片上传就坏了是最难排查的一类问题因为它不是上传本身坏了。上传由 PHP 内核负责几乎不受版本影响真正被版本影响的是三件事$_FILES之后你调用的那些函数、错误输出的位置、以及 ini 配置的默认值。典型症状可以分成三组。第一组是白屏或 500日志里写着Call to undefined function image2wbmp()或Call to undefined function each()——这些函数在 PHP 8.0 被移除了。第二组是接口返回的 JSON 解析失败PHP 8.1 起向内置函数的非可空参数传 null会发弃用告警告警被display_errors打进响应体Content-Type: application/json的响应里混进了 HTML前端只看到上传失败。第三组是文件根本没到 PHP$_POST和$_FILES同时为空代码里$_FILES[file]触发未定义下标而根因往往是post_max_size或 nginx 的client_max_body_size。本文按先确定失败在哪一层、再处理版本差异的顺序给出一份可直接运行的诊断脚本和一个安全的上传处理函数最低 PHP 8.0并列出这类问题里最容易踩的坑。一、先定位失败在哪一层上传链路有四层任何一层不通过现象都不一样层级关键配置失败现象浏览器accept属性、前端大小校验用户侧被拦请求未发出Web 服务器nginxclient_max_body_size默认 1m413PHP 侧无任何日志PHP iniupload_max_filesize、post_max_size、max_file_uploads、upload_tmp_dir超限时$_FILES为空或error非 0业务代码getimagesize()、GD、finfo、move_uploaded_file()上传成功但缩略图/校验炸掉一个必须记住的细节当请求体超过post_max_size时PHP 会同时清空$_POST和$_FILES并且不抛异常。代码里写$_FILES[file][name]就会访问不存在的键。在 PHP 8.0 之前这只是 Notice 加值为 null在 8.0 及以后是 Warning仍不是致命错误但配合把 null 传给strlen()就会在 8.1 起额外产生弃用告警——多个版本的问题叠在一起日志看起来完全不像同一个原因。判断方法很简单看$_SERVER[CONTENT_LENGTH]。它有值而$_FILES为空就是这一层的问题。二、PHP 8.0 移除的函数上传后处理的重灾区PHP 8.0 移除了一批在 7.x 中已弃用的函数图片处理相关的最典型是两个image2wbmp()和png2wbmp()7.3 起弃用8.0 移除。老项目里上传 BMP/PNG 后转 WBMP 输出缩略图的代码升级后会直接Fatal error。除此之外还有each()、create_function()、get_magic_quotes_gpc()、money_format()等其中each()常出现在老式上传循环里while (list($k, $v) each($_FILES))。同一版本还有一些行为变更不报错但结果不同变更PHP 7.xPHP 8.0substr($s, strlen($s))返回false返回explode(, $s)返回false抛ValueErrorimage2wbmp()/png2wbmp()弃用告警函数不存在each()弃用告警函数不存在未定义下标取值NoticeWarning对上传流程而言explode()那一条最阴老代码用explode(, $path)或explode(, $type)想拆字符本来就是错的写法在 7.x 上静默返回false被忽略到 8.0 变成抛异常直接中断流程。三、PHP 8.1 / 8.2 / 8.4 的弃用告警如何伪装成上传失败弃用Deprecated本身不致命但它会污染输出8.1起向内置函数的非可空参数传null会被标记弃用。上传代码里$_FILES[file][name]在文件缺失时是null后续strlen(null)、preg_match($p, null)、htmlspecialchars(null)全会各发一条弃用告警。8.1还给$_FILES增加了full_path键配合目录上传解析$_FILES的自研代码要留意多出来的键。8.2起动态属性弃用。老的上传处理类常写成构造函数外部随手$this-foo ...会开始刷告警。8.4起隐式可空参数function f(string $name null)弃用。上传回调里这种写法很常见。如果display_errors On这些告警会插到响应体里。一个返回 JSON 的上传接口响应就变成Warning: ...{ok:true}这种混合内容JSON.parse直接失败。前端提示上传失败而后端日志里只有一堆无害的告警——这就是典型的现象与根因不在同一层。四、诊断脚本与安全的上传处理先跑一份环境诊断把版本和 ini 摊开看?php // 最低版本PHP 8.0 declare(strict_types1); header(Content-Type: text/plain; charsetUTF-8); /** 把 8M 1G 512K 这类 ini 值换算成字节 */ function toBytes(string $size): int { $size trim($size); $unit strtolower(substr($size, -1)); $value (int) $size; return match ($unit) { g $value * 1024 ** 3, m $value * 1024 ** 2, k $value * 1024, default $value, }; } $ini static fn(string $k): string ini_get($k) false ? (未设置) : (string) ini_get($k); printf(PHP 版本 : %s\n, PHP_VERSION); printf(file_uploads : %s\n, $ini(file_uploads)); printf(upload_max_filesize : %s %d 字节\n, $ini(upload_max_filesize), toBytes($ini(upload_max_filesize))); printf(post_max_size : %s %d 字节\n, $ini(post_max_size), toBytes($ini(post_max_size))); printf(max_file_uploads : %s\n, $ini(max_file_uploads)); printf(upload_tmp_dir : %s\n, $ini(upload_tmp_dir)); printf(display_errors : %s\n, $ini(display_errors)); printf(error_reporting : %s\n, $ini(error_reporting)); printf(CONTENT_LENGTH : %s\n, $_SERVER[CONTENT_LENGTH] ?? (无)); foreach ([gd, fileinfo, exif, imagick] as $ext) { printf(扩展 %-9s : %s\n, $ext, extension_loaded($ext) ? 已启用 : 未启用); } // 关键一致性检查 if (toBytes($ini(post_max_size)) toBytes($ini(upload_max_filesize))) { echo \n[风险] post_max_size 小于 upload_max_filesize表单一旦超过 post_max_size\$_POST 与 \$_FILES 会被同时清空。\n; } if (!extension_loaded(fileinfo)) { echo \n[风险] 缺少 fileinfo无法可靠判断文件的真实 MIME 类型。\n; }安全的上传处理函数?php // 最低版本PHP 8.0 declare(strict_types1); /** * param arraystring,mixed $file $_FILES 里的单个条目 * return string 落盘后的绝对路径 */ function saveUpload(array $file, string $destDir, int $maxBytes 5_000_000): string { $code $file[error] ?? UPLOAD_ERR_NO_FILE; if ($code ! UPLOAD_ERR_OK) { $messages [ UPLOAD_ERR_INI_SIZE 文件超过 upload_max_filesize 限制, UPLOAD_ERR_FORM_SIZE 文件超过表单 MAX_FILE_SIZE 限制, UPLOAD_ERR_PARTIAL 文件只上传了一部分请重试, UPLOAD_ERR_NO_FILE 没有选择文件, UPLOAD_ERR_NO_TMP_DIR 服务器缺少临时目录, UPLOAD_ERR_CANT_WRITE 服务器写入临时文件失败, UPLOAD_ERR_EXTENSION 某个扩展中断了上传, ]; throw new RuntimeException($messages[$code] ?? 上传失败错误码 {$code}); } $tmp (string) ($file[tmp_name] ?? ); if ($tmp || !is_uploaded_file($tmp)) { throw new RuntimeException(临时文件校验失败); } // 以服务器端的实际大小为准不信客户端提交的 size $size filesize($tmp); if ($size false || $size 0) { throw new RuntimeException(文件为空或不可读); } if ($size $maxBytes) { throw new RuntimeException(文件超过允许大小); } // 1) 用 finfo 判断真实 MIME $finfo new finfo(FILEINFO_MIME_TYPE); $mime $finfo-file($tmp); $map [ image/jpeg jpg, image/png png, image/gif gif, image/webp webp, ]; if ($mime false || !isset($map[$mime])) { throw new RuntimeException(只允许上传 JPG / PNG / GIF / WebP 图片); } // 2) 再用 getimagesize 交叉验证确认真的是图片 $info getimagesize($tmp); $okTypes [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP]; if ($info false || !in_array($info[2], $okTypes, true)) { throw new RuntimeException(文件内容不是有效的图片); } // 3) 文件名由服务端生成绝不使用客户端文件名 $target rtrim($destDir, /\\) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(16)) . . . $map[$mime]; if (!move_uploaded_file($tmp, $target)) { throw new RuntimeException(保存文件失败请检查目录权限); } chmod($target, 0640); return $target; }关键点是三重校验error码、finfo的真实 MIME、getimagesize()的内容校验。三者都过基本可以认为这是一张图片。常见坑点1. 用$_FILES[f][type]判断图片类型❌if ($_FILES[f][type] image/jpeg)——这个值由浏览器提供可以随便伪造不同浏览器给同一张图的值还不一样有的是image/pjpeg有的是image/jpg症状是某些用户的图片莫名其妙被拒绝。 ✅ 用finfo读真实 MIME再叠加getimagesize()。2. 升级到 8.0 后还在调用已移除的 GD 函数❌image2wbmp($im)/png2wbmp($im, $file)——7.3 起弃用、8.0 起彻底移除调用即Fatal error: Call to undefined function整站 500。 ✅ 改用imagewbmp()或者干脆输出 PNG/JPEG用function_exists()做能力检测能让老代码平滑过渡。3. 不检查$_FILES[f][error]❌ 直接拿tmp_name去move_uploaded_file()——文件超过upload_max_filesize时error是 1tmp_name为空字符串move_uploaded_file()只会返回 false报错信息毫无指向性。 ✅ 先判error码再把每个错误码翻译成人话。4. 请求体超过post_max_size时$_FILES直接不存在❌$name $_FILES[f][name];——此时$_POST与$_FILES全空PHP 8 下是 Warning 加null后面的strlen(null)在 8.1 起又发弃用告警日志里全是次生问题真正的根因表单太大反而找不到。 ✅ 入口处先判断if ($_SERVER[REQUEST_METHOD] POST empty($_POST) empty($_FILES) ($_SERVER[CONTENT_LENGTH] ?? 0) 0)给出上传内容过大的明确提示。5. 只调upload_max_filesize忘了post_max_size❌ 把upload_max_filesize改成 20Mpost_max_size还是默认的 8M——超过 8M 的请求体直接消失用户看到的是提交后什么都没发生。 ✅post_max_size必须大于upload_max_filesize因为表单里还有其它字段例如 20M 配 24M。6. nginx 的client_max_body_size卡在最前面❌ 默认值是 1m大于 1M 的图片返回 413PHP 端连日志都没有排查时一直盯着 PHP 配置看。 ✅ nginx 里显式设置client_max_body_size 24m;并让它与 PHP 的post_max_size对齐。7. 用或无返回值检查的方式调move_uploaded_file()❌move_uploaded_file($tmp, $target);——目录不可写、open_basedir限制、目标已存在都会静默失败然后代码继续往数据库写一条指向不存在文件的记录。 ✅ 检查返回值并抛异常open_basedir和目录权限问题在 PHP 8 下同样会返回 false不会自动升级成致命错误。8. 让弃用告警进入响应体❌ 生产环境开着display_errorsContent-Type: application/json的接口吐出前置的 Deprecated 文本前端JSON.parse失败用户看到上传失败而后端日志里全是已弃用级别的噪音。 ✅ 生产环境display_errorsOff、log_errorsOn响应体只由业务代码产出。总结现象常见根因处理方向500 / 白屏8.0 移除了image2wbmp()、png2wbmp()、each()等替换为新函数或加function_exists()兜底JSON 解析失败8.1 起的 null 传参弃用告警混进响应体关display_errors补齐 null 判断$_FILES为空请求体超过post_max_size先看CONTENT_LENGTH再调 ini413无 PHP 日志nginxclient_max_body_size默认 1m在 nginx 侧放开上传成功但缩略图失败类型校验只信$_FILES[type]或 GD 处理报错finfogetimagesize()双重校验处理版本不兼容导致的上传失败最有效的顺序是先分层Web 服务器 / ini / 代码再定版本只要确认了请求有没有到达 PHP剩下就是查函数是否存在、告警是否污染输出这两件事。把error码、真实 MIME、内容校验这三道关卡写进统一的处理函数以后换 PHP 版本时最先出问题的只会是那几处已移除的旧函数而不是整条上传链路。