前言上传功能出问题的形态很固定用户传了一张 3 MB 的图页面刷新后$_FILES是空的连报错都没有或者接口返回 413可 PHP 日志里一条记录都没有再或者明明在代码里写了只允许 jpg用户把shell.php改名为shell.jpg就传上来了。先把版号这件事说清楚文件上传的大小与数量限制不是 PHP 8.5 的新特性。upload_max_filesize、post_max_size、max_file_uploads、file_uploads这些是 php.iniPHP 配置文件里的指令从 PHP 4/5 时代就存在PHP 8.5 并没有为上传格式限制引入任何新语法或新指令。有些文章把上传配置写成8.5 新特性是不准确的。因此本文按在当前 PHP包括 8.5上怎么做对来讲下面所有配置在 PHP 8.0 及以上都通用。本文要解决三件事搞清楚限制到底卡在哪一层PHP 配置、Web 服务器、应用代码为什么只校验扩展名等于没有校验以及一份可以直接拿来用的上传处理代码。一、限制分三层报错却长得不一样上传请求要依次穿过三层的限制每层的失败表现完全不同这是排查时最需要先分清的事层关键配置超限时的表现Web 服务器Nginxclient_max_body_size、ApacheLimitRequestBodyHTTP 413PHP 完全收不到请求日志无记录PHP 配置upload_max_filesize、post_max_size、max_file_uploads$_FILES为空或error为非 0常见UPLOAD_ERR_INI_SIZE应用代码白名单、大小、MIME 校验由你的代码决定可以给出人类可读的错误三个层次里Nginx 的client_max_body_size默认是1m这是本地测试好好的、上线传大文件就 413的头号原因而post_max_size小于文件体积时PHP 会在解析请求体前就放弃导致$_POST和$_FILES双双为空——代码里如果只判断isset($_FILES[upload])就会静默地什么都不做。$_FILES[x][error]的取值需要背下来注意没有 5常量值含义UPLOAD_ERR_OK0成功UPLOAD_ERR_INI_SIZE1超过upload_max_filesizeUPLOAD_ERR_FORM_SIZE2超过表单里声明的MAX_FILE_SIZEUPLOAD_ERR_PARTIAL3只上传了一部分UPLOAD_ERR_NO_FILE4没有选择文件UPLOAD_ERR_NO_TMP_DIR6缺少临时目录UPLOAD_ERR_CANT_WRITE7写磁盘失败UPLOAD_ERR_EXTENSION8被某个扩展中止所以一个稳妥的判断顺序是先看$_SERVER[CONTENT_LENGTH]是否超过post_max_size超过时$_FILES为空必须靠这个兜住再看error码最后才做业务校验。二、为什么扩展名和白名单都必须校验但都不能只靠一个$_FILES[x][type]是客户端提交的 MIME 类型随便改一个请求就能伪造绝不能作为安全依据$_FILES[x][name]同样是客户端给的直接拿它拼路径就是目录穿越漏洞。判定的正确顺序是用basename()剥掉客户端文件名里的路径只用来做展示与日志用finfoFileinfo 扩展读取服务器本地临时文件的真实内容类型与白名单比对对图片类再用getimagesize()确认它真能被解析为图片并重新编码一次顺手把图片马polyglot file即一半是图片一半是 PHP 代码的混合文件里的附加数据洗掉落盘时用随机生成的文件名 白名单里定死的扩展名彻底丢弃客户端的命名。其中第 3 步经常被忽略但它是防图片马的关键getimagesize()只看文件头一个 GIF 头后面跟 PHP 代码的文件照样能通过而用 GD 重新编码一张图会丢掉所有非像素数据。另外提醒一点SVG 虽然以image/svgxml通过校验本质却是 XML内联展示时可以执行脚本属于 XSS跨站脚本风险。除非确有需求建议把 SVG 排除在白名单之外或者只允许下载、不允许内联渲染。三、实战可运行的上传处理器下面这份代码把上面四步串起来保存为upload.php表单用 POST multipart/form-data字段名为upload。需要 PHP 8.0 及以上图片重编码需要 GD 扩展代码里做了存在性判断没有 GD 时会退化为仅校验不重编码。?php // upload.php —— 需要 PHP 8.0 及以上建议开启 fileinfo 与 gd 扩展 declare(strict_types1); const UPLOAD_DIR __DIR__ . /storage/uploads; const MAX_BYTES 8 * 1024 * 1024; // 单文件 8 MB需小于 php.ini 的限制 /** 内容类型 落盘时使用的扩展名白名单即格式限制的真正落点 */ const ALLOWED_TYPES [ image/jpeg jpg, image/png png, image/gif gif, image/webp webp, application/pdf pdf, ]; function humanSize(int $bytes): string { return number_format($bytes / 1048576, 2) . MB; } /** return array{ok:bool, message:string, file?:string} */ function handleUpload(array $file): array { // 0) post_max_size 超限时 $_FILES 为空这类情况由调用方提前兜住 $err $file[error] ?? UPLOAD_ERR_NO_FILE; if ($err ! UPLOAD_ERR_OK) { return [ok false, message match ($err) { UPLOAD_ERR_INI_SIZE 文件超过服务器 upload_max_filesize 限制 . ini_get(upload_max_filesize) . , UPLOAD_ERR_FORM_SIZE 文件超过表单声明的最大长度, UPLOAD_ERR_PARTIAL 文件只上传了一部分请重试, UPLOAD_ERR_NO_FILE 没有选择文件, UPLOAD_ERR_NO_TMP_DIR 服务器缺少临时目录请联系管理员, UPLOAD_ERR_CANT_WRITE 服务器写磁盘失败, UPLOAD_ERR_EXTENSION 上传被某个 PHP 扩展中止, default 未知上传错误, }]; } // 1) 大小与确实是上传文件的双重确认 if ($file[size] MAX_BYTES) { return [ok false, message 文件过大上限 . humanSize(MAX_BYTES)]; } if (!is_uploaded_file($file[tmp_name])) { return [ok false, message 临时文件校验失败]; } // 2) 用真实内容类型做白名单判断绝不使用 $_FILES[type] $finfo new finfo(FILEINFO_MIME_TYPE); $mime $finfo-file($file[tmp_name]) ?: application/octet-stream; if (!isset(ALLOWED_TYPES[$mime])) { return [ok false, message 不允许的格式: {$mime}]; } // 3) 图片额外处理解析 重编码洗掉夹带的数据 $ext ALLOWED_TYPES[$mime]; if (str_starts_with($mime, image/) $mime ! image/svgxml) { if (extension_loaded(gd)) { $data file_get_contents($file[tmp_name]); $im $data false ? false : imagecreatefromstring($data); if ($im false) { return [ok false, message 图片内容无法解析]; } imagealphablending($im, false); imagesavealpha($im, true); imagepng($im, $file[tmp_name]); // 重新编码同时把格式统一为 png imagedestroy($im); $ext png; } else { // 无 GD 时至少确认文件头可解析 if (getimagesize($file[tmp_name]) false) { return [ok false, message 图片内容无法解析]; } } } // 4) 落盘随机文件名 白名单扩展名客户端文件名只用于展示 if (!is_dir(UPLOAD_DIR) !mkdir(UPLOAD_DIR, 0750, true) !is_dir(UPLOAD_DIR)) { return [ok false, message 上传目录不可用]; } $name bin2hex(random_bytes(16)) . . . $ext; $dest UPLOAD_DIR . DIRECTORY_SEPARATOR . $name; if (!move_uploaded_file($file[tmp_name], $dest)) { return [ok false, message 保存文件失败]; } chmod($dest, 0640); return [ok true, message 上传成功, file $name]; } // ---- 入口 ---- if (($_SERVER[REQUEST_METHOD] ?? ) ! POST) { http_response_code(405); exit(请使用 POST 提交); } // post_max_size 超限的兜底此时 $_POST 与 $_FILES 都是空的 $contentLength (int) ($_SERVER[CONTENT_LENGTH] ?? 0); if ($contentLength 0 empty($_POST) empty($_FILES)) { http_response_code(413); exit(请求体过大可能超过 post_max_size请减小文件后重试); } $result handleUpload($_FILES[upload] ?? []); http_response_code($result[ok] ? 200 : 422); echo json_encode($result, JSON_UNESCAPED_UNICODE), PHP_EOL;配套的配置分两处。php.ini或 php-fpm 池配置里的php_admin_value[...]因为upload_max_filesize与post_max_size属于PHP_INI_PERDIR不能在脚本里用ini_set()改file_uploads On upload_max_filesize 8M post_max_size 10M ; 必须大于 upload_max_filesize且要容纳表单其他字段 max_file_uploads 20 ; 单次请求最多文件数 upload_tmp_dir /var/lib/php/uploads ; 需对 php-fpm 运行用户可写反向代理那一层同样要放行否则请求根本到不了 PHP表现为 413且 PHP 日志干净server { listen 80; server_name upload.example.test; client_max_body_size 10m; # 默认 1m需要与 post_max_size 对齐 location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 120s; # 大文件上传慢别让网关先超时 } }常见坑点❌ 只判断isset($_FILES[upload])就开始处理post_max_size超限时$_FILES为空程序静默什么都不做✅ 先用$_SERVER[CONTENT_LENGTH]与ini_get(post_max_size)比对兜底再进去看error码❌ 信任$_FILES[upload][type]和[name]做格式判断与文件名✅type由客户端提交可随意伪造name可能带路径。判定用finfo读本地临时文件命名用随机串 白名单扩展名❌ 用strrchr($name, .)取最后一个点之后就认为是安全后缀✅ 双重扩展名shell.php.jpg与大小写混写都要靠白名单收口不匹配允许的类型就直接拒绝不做任何容错解析❌ 在脚本里写ini_set(upload_max_filesize, 50M)想放宽限制结果毫无效果✅ 它是PHP_INI_PERDIR级别只能在 php.ini、.htaccess、.user.ini或 php-fpm 池配置里设置改完重启进程❌ 只放大了upload_max_filesize忘了同步加大post_max_size和 Nginx 的client_max_body_size✅ 三处要一起放大且post_max_size略大于upload_max_filesize代理层不小于post_max_size❌ 把上传目录放在 Web 根目录下还允许按原文件名保存✅ 目录放在 Web 根之外或用 Nginx 规则禁止该目录执行脚本文件名随机化避免上传即执行的链条成立❌ 只用getimagesize()判断图片接受 GIF/PNG 混合文件✅ 有 GD 时用imagecreatefromstring()imagepng()重新编码物理上抹掉夹带内容❌ 忘记mkdir返回值判断也没给upload_tmp_dir足够权限用户看到的是UPLOAD_ERR_CANT_WRITE却没有日志✅ 检查upload_tmp_dir与目标目录的属主/权限并把error码翻译成人话返回给用户日志里留下原始码总结限制点配置项传错/配错的症状代理层请求体Nginxclient_max_body_size413PHP 无日志单文件大小upload_max_filesizeUPLOAD_ERR_INI_SIZE1整个请求体post_max_size$_POST/$_FILES同时为空文件个数max_file_uploads超出部分被静默丢弃临时目录upload_tmp_dirUPLOAD_ERR_NO_TMP_DIR6或写失败7格式限制应用层finfo白名单 重编码伪造扩展名与图片马绕过记住一句话PHP 配置负责能收多大的文件Web 服务器负责请求体放不放行应用代码负责这个文件到底是不是它自称的格式。三层各管一段任何一层缺位都会出现文中那些没有报错却失败的诡异现象。至于版号上传限制跟 PHP 8.5 没有特殊关系你在 PHP 8.0 上照这套配置做行为是一致的。