PHP 语法进阶 13|超全局变量与表单:`$_POST` 里永远没有整数

九个超全局变量

变量 内容
$_GET URL 查询串(?a=1&b=2)
$_POST 请求体为表单编码时解析出来的字段
$_REQUEST 默认合并了 GET/POST/Cookie,不要用
$_COOKIE Cookie
$_SESSION 会话数据(session_start() 后才有)
$_SERVER 服务器与请求信息
$_FILES 上传的文件
$_ENV 环境变量(取决于variables_order 配置)
$GLOBALS 所有全局变量的数组

$_REQUEST 为什么不能用:它的内容取决于 request_order 配置,不同环境合并顺序不同,会造成"本地读到的值和线上不一样"。而且它会被 Cookie 污染,便于构造攻击。明确用 $_GET 还是 $_POST。

第一条铁律:进来的都是字符串

<?php
// URL: /list.php?page=3&tags[]=a&tags[]=b
var_dump($_GET['page']);    // string(1) "3"   —— 不是 int
var_dump($_GET['tags']);    // array(2) { [0]=> string(1) "a" ... }

HTTP 传的永远是文本。类型要自己转,而且要在校验之后转:

<?php
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [
    'options' => ['default' => 1, 'min_range' => 1, 'max_range' => 1000],
]);
// $page 此时是真正的 int;非法时是 1

filter_input() 的好处:取值 + 校验 + 默认值一步完成,而且不合法时不会给你一个"看着像数字的字符串"。

<?php
filter_var("abc@example.com", FILTER_VALIDATE_EMAIL);      // "abc@example.com"
filter_var("not-an-email", FILTER_VALIDATE_EMAIL);         // false
filter_var("https://a.com/x?y=1", FILTER_VALIDATE_URL);    // 合法
filter_var("1", FILTER_VALIDATE_BOOLEAN);                  // true
filter_var("abc", FILTER_VALIDATE_INT);                    // false

filter_var 的返回是 false 表示失败,所以不能写 if (!$v)(0 也是 falsy),要写 === false。

表单的基本形态

<form method="post" action="/save.php">
    <input name="title" type="text">
    <input name="tags[]" type="text">
    <input name="tags[]" type="text">
    <input name="user[name]" type="text">
    <input name="avatar" type="file">
    <button type="submit">提交</button>
</form>

name="tags[]" 会让 PHP 把多个同名输入收成一个数组;name="user[name]" 会收成嵌套数组 $_POST['user']['name']。这是 PHP 特有的表单命名约定。

如果表单有文件上传,必须加 enctype="multipart/form-data",否则 $_FILES 是空的,且 $_POST 里的非文件字段仍然正常。这个"缺 enctype"的 bug 极常见,因为它的症状只是"文件没上传成功",不会报错。

处理 POST 的三种请求体

Content-Type PHP 会不会自动填$_POST
application/x-www-form-urlencoded 会
multipart/form-data 会(且填$_FILES)
application/json 不会
text/plain 不会

所以前端用 fetch 发 JSON 时,后端必须自己读原始请求体:

<?php
$raw = file_get_contents("php://input");
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);

// 校验一定要做:JSON 出来的类型也不可信
if (!isset($data['name']) || !is_string($data['name'])) {
    http_response_code(422);
    echo json_encode(["error" => "缺少 name"]);
    exit;
}

php://input 在 multipart/form-data 的情况下不可用(PHP 会自己解析掉),其他情况下可以重复读(PHP 5.6 之后)。

判断请求方法

<?php
$method = $_SERVER['REQUEST_METHOD'];   // GET / POST / PUT / DELETE ...

if ($method !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit;
}

顺手提一句:$_SERVER['REQUEST_METHOD'] 是唯一可信的方法来源。不要通过 $_POST 里有没有某个字段来判断"是不是提交了表单",因为 GET 请求也能带参数。

PUT/DELETE 请求的请求体也不会进 $_POST,同样要读 php://input。

文件上传:$_FILES 的结构与错误码

<?php
// <input type="file" name="avatar">
var_dump($_FILES['avatar']);

结构:

[
    'name'     => 'photo.jpg',        // 客户端给的文件名,完全不可信
    'type'     => 'image/jpeg',       // 客户端给的 MIME,完全不可信
    'tmp_name' => '/tmp/phpXXXX',     // 服务器上的临时路径
    'error'    => 0,
    'size'     => 123456,
]

七个错误码:

常量 值 含义
UPLOAD_ERR_OK 0 成功
UPLOAD_ERR_INI_SIZE 1 超过upload_max_filesize
UPLOAD_ERR_FORM_SIZE 2 超过表单的MAX_FILE_SIZE
UPLOAD_ERR_PARTIAL 3 只上传了一部分
UPLOAD_ERR_NO_FILE 4 没选文件
UPLOAD_ERR_NO_TMP_DIR 6 缺临时目录
UPLOAD_ERR_CANT_WRITE 7 写磁盘失败
UPLOAD_ERR_EXTENSION 8 被扩展阻止

error === 0 不等于文件安全,只表示"传输成功"。

一个完整、安全的处理流程:

<?php
/** 安全的上传处理 */
function saveUpload(array $file, string $dir, array $allowMime = ['image/jpeg', 'image/png', 'image/webp']): string
{
    // 1) 基础错误检查
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException("上传失败,错误码 {$file['error']}");
    }

    // 2) 必须确认是真正的上传文件(防止路径伪造)
    if (!is_uploaded_file($file['tmp_name'])) {
        throw new RuntimeException("非法的临时文件");
    }

    // 3) 大小限制(不要相信 $file['size'],以实际文件为准)
    if ($file['size'] > 5 * 1024 * 1024) {
        throw new RuntimeException("文件超过 5MB");
    }

    // 4) 用 finfo 读真实 MIME,不看 $_FILES['type']
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);
    if (!in_array($mime, $allowMime, true)) {
        throw new RuntimeException("不支持的类型:{$mime}");
    }

    // 5) 扩展名从 MIME 推导,不采用客户端文件名
    $ext = match ($mime) {
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/webp' => 'webp',
        default      => 'bin',
    };

    // 6) 生成随机文件名,避免覆盖与路径穿越
    $name = bin2hex(random_bytes(16)) . '.' . $ext;
    $target = rtrim($dir, '/') . '/' . $name;

    if (!move_uploaded_file($file['tmp_name'], $target)) {
        throw new RuntimeException("保存失败");
    }

    return $target;
}

三条要点展开:

  • finfo 读文件头判断 MIME。$_FILES['type'] 是浏览器填的,攻击者改个请求头就能伪造。哪怕后缀是 .jpg,finfo 也能识别出它其实是 .php。
  • is_uploaded_file() + move_uploaded_file()。后者会检查"这个临时文件是不是通过 HTTP 上传来的",是防文件包含攻击的关键一环。
  • 文件名要自己生成。用用户提供的名字,就可能出现 ../../config.php 这种路径穿越。

另外,上传目录必须禁止执行 PHP。Nginx 配 location ~ \.php$ { deny all; },Apache 用 .htaccess 关掉 php_flag engine off。这是"上传图片结果变成 webshell"的最后一道门。

相关的 php.ini 配置

file_uploads = On
upload_max_filesize = 8M
post_max_size = 10M            ; 必须大于 upload_max_filesize
max_file_uploads = 20
upload_tmp_dir = /tmp
max_execution_time = 30

一个经典现象:post_max_size 被超过时,$_POST 和 $_FILES 会整个变空,而且不报任何错。 用户看到的是"什么都没提交上来"。

<?php
// 判断"是否因为超过 post_max_size 被丢弃"
if ($_SERVER['REQUEST_METHOD'] === 'POST'
    && empty($_POST) && empty($_FILES)
    && (int)$_SERVER['CONTENT_LENGTH'] > 0) {
    http_response_code(413);
    echo "请求体过大,请减小文件或分批提交";
    exit;
}

这段判断很实用,能避免"用户传大文件,页面一片空白"的困惑。

$_SERVER 里真正有用的几个

<?php
$_SERVER['REQUEST_METHOD'];    // 请求方法
$_SERVER['REQUEST_URI'];       // 原始请求路径 + 查询串
$_SERVER['SCRIPT_NAME'];       // 当前脚本路径
$_SERVER['HTTP_HOST'];         // Host 头(可伪造,别用于安全判断)
$_SERVER['REMOTE_ADDR'];       // 对端 IP
$_SERVER['REMOTE_PORT'];
$_SERVER['CONTENT_TYPE'];
$_SERVER['CONTENT_LENGTH'];    // 请求体字节数
$_SERVER['HTTPS'];             // 是否 https('on' 或空)

关于客户端真实 IP:REMOTE_ADDR 是对端地址。在代理后面它会是代理 IP。X-Forwarded-For 可以读,但只有在你确认"这个头确实由自己的代理写入"时才可信,否则任何人都能伪造它来绕过 IP 限流。

输入校验的最简实践

<?php
function input(string $key, string $source = 'post'): string
{
    $bag = $source === 'get' ? $_GET : $_POST;
    $v = $bag[$key] ?? '';
    return is_string($v) ? trim($v) : '';
}

$title = input('title');
if ($title === '') {
    $errors['title'] = '标题不能为空';
} elseif (mb_strlen($title) > 100) {
    $errors['title'] = '标题最多 100 字';
}

校验用"白名单"不是"黑名单"。判断"是否是合法邮箱"比"是否不含 <"可靠得多。而且校验不住的地方,输出时还要再转义一次(详见第 18 篇)。

坑清单

  1. $_POST/$_GET 里的值是字符串,"1" == 1 之外还有 "abc" == 0 的历史坑。
  2. 用 $_REQUEST,结果依赖配置、可被 Cookie 污染。
  3. 表单缺 enctype,文件字段静默丢失。
  4. JSON 请求体不会进 $_POST,要读 php://input。
  5. 用 $_FILES['type'] 判断文件类型,可被伪造。
  6. 用客户端文件名拼路径,造成路径穿越。
  7. post_max_size 超限后 $_POST 静默为空。
  8. filter_var 失败返回 false,用 !$v 判断会把 0 误判成失败。
  9. 上传目录可执行 PHP,图片变 webshell。