第4章 4.3 文件上传——让用户把文件「丢」进你的网站
上章我们聊了 Cookie 和 Session,知道怎么把用户的状态「记住」了。但光记住状态还不够——你总不能让人家每次都要重新填写一大段文字吧?
比如说,用户想上传一张头像、提交一份简历、或者批量导入一批客户数据……这时候,你就需要用到文件上传了。
这章我们就来解决这个问题:怎么让用户把文件「丢」进你的网站,然后你稳稳当当地接住它。
🎯 开场 3 分钟:为什么要学这个?
场景再现
你有没有遇到过这种情况:
- 注册账号时,想让用户上传头像,但不知道怎么做
- 后台需要用户提交 Excel 表格,但只会手动一条条录入
- 想做一个图床服务,让别人上传图片,结果图片全丢了
这些场景的共同点是:数据不在数据库里,而是在用户的硬盘上。
学完这章你能做到
- 接收用户上传的文件(单个 + 批量)
- 验证文件的类型和大小,防止乱传
- 把文件存到指定位置,还能按日期分类整理
- 做一个带文件上传功能的迷你工具(比如图片\n\n
\n\n
\n\n收集器)
🧱 基础 25 分钟:核心概念
概念 1:文件上传的「快递员」——$_FILES
在 PHP 里,用户上传的文件会经过一个「快递员」送到你手上,这个快递员就是 $_FILES。
类比一下:就像你网购后,快递员把包裹送到快递柜,然后给你发一个取件码。$_FILES 就是那个告诉你「有包裹到了,来取吧」的系统。
当你上传一个文件后,$_FILES 会记录这个文件的信息:
<?php
// 假设用户上传了一个头像,名字叫 avatar
// $_FILES 里面大概是这样:
print_r($_FILES['avatar']);
输出大概长这样:
Array
(
[name] => "touxiang.jpg" // 文件原始名字
[type] => "image/jpeg" // MIME 类型
[tmp_name] => "/tmp/phpXXXXX" // 服务器上的临时路径
[error] => 0 // 错误码,0表示没问题
[size] => 123456 // 文件大小,单位是字节
)
一句话解释:$_FILES 是 PHP 给你提供的「快递单查询系统」,里面写着文件的来龙去脉。
概念 2:临时文件与永久存储——tmp_name
你可能注意到了,上传的文件先到了一个叫 tmp_name 的地方。这是一个临时存放点,类似于快递柜的暂存格。
为什么要临时存放?
因为文件上传是分步进行的:
1. 用户把文件「丢」到你的网站
2. 服务器先把文件收到临时目录
3. 你检查没问题后,手动把它移到正式目录
类比:就像你寄大件快递,快递员先放到快递点,等你确认签收后才真正送到家门口。
概念 3:move_uploaded_file——文件「搬家」
临时目录的文件不会一直待在那儿,PHP 会定期清理。所以你得在它消失之前,把文件搬到你想要的正式位置。
这就用到了 move_uploaded_file():
<?php
// 把临时文件搬到指定位置
$tmp_file = $_FILES['avatar']['tmp_name']; // 临时文件在哪
$new_path = "./uploads/" . $_FILES['avatar']['name']; // 搬到哪去
move_uploaded_file($tmp_file, $new_path); // 执行搬家
一句话解释:move_uploaded_file 就是帮你把文件从快递柜搬到用户家里的函数。
概念 4:MIME 类型——文件的「身份证」
每个文件都有自己的 MIME 类型,相当于人的身份证。比如:
- 图片的 MIME 是 image/jpeg、image/png
- PDF 是 application/pdf
- 文本是 text/plain
为什么要验证 MIME?
用户可能上传一个「假装自己是图片的病毒文件」。你得「验明正身」,确保它真的是图片。
<?php
// 获取文件的 MIME 类型
$file_type = $_FILES['avatar']['type'];
if ($file_type === 'image/jpeg' || $file_type === 'image/png') {
echo "是图片,允许上传";
} else {
echo "不是图片,拒绝!";
}
概念 5:多文件上传——一次传一批
有时候用户要上传的不止一个文件,比如一次发多张图片。这时候 PHP 也能搞定。
HTML 端要这样写:
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="file" name="pictures[]" multiple>
<button type="submit">上传多张图片</button>
</form>
注意 name="pictures[]" 里的 [],这告诉 PHP「这是一个数组,我要收多个文件」。
PHP 端这样接:
<?php
// 循环处理每个文件
foreach ($_FILES['pictures']['name'] as $index => $name) {
$tmp = $_FILES['pictures']['tmp_name'][$index];
$new_path = "./uploads/" . $name;
move_uploaded_file($tmp, $new_path);
echo "第 " . ($index + 1) . " 张图片上传成功:$name<br>";
}
🔥 实战 35 分钟:3 个递进的小项目
项目 1:5 分钟——最基础的头像上传
需求:用户上传一张头像,存到 uploads 目录。
先创建目录结构:
/www
├── uploads/ # 创建这个目录,并给它写入权限
├── index.php # 上传表单
└── upload.php # 处理上传的逻辑
index.php(上传表单):
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>头像上传</title>
</head>
<body>
<h2>上传你的头像</h2>
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*">
<button type="submit">上传</button>
</form>
</body>
</html>
upload.php(处理逻辑):
<?php
// 1. 检查有没有文件
if (!isset($_FILES['avatar'])) {
die("没有选择文件");
}
// 2. 检查上传有没有错误
if ($_FILES['avatar']['error'] !== 0) {
die("上传失败,错误码:" . $_FILES['avatar']['error']);
}
// 3. 检查文件类型(必须是图片)
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($_FILES['avatar']['type'], $allowed_types)) {
die("只能上传 JPG/PNG/GIF 图片");
}
// 4. 把文件搬到正式目录
$tmp_file = $_FILES['avatar']['tmp_name'];
$new_path = "./uploads/" . $_FILES['avatar']['name'];
if (move_uploaded_file($tmp_file, $new_path)) {
echo "上传成功!文件已保存为:" . $_FILES['avatar']['name'];
} else {
echo "保存失败";
}
预期输出(成功后):
上传成功!文件已保存为:touxiang.jpg
一句话解释:核心就三步——检查文件、验证类型、执行搬家。
项目 2:15 分钟——带日期分类的图片上传
需求:做一个图片收集器,按「年/月/日」自动分类存储。
upload_v2.php:
<?php
/**
* 图片收集器 v2
* 功能:自动按日期分类存储
*/
// 1. 检查文件
if (!isset($_FILES['images'])) {
die("没有选择文件");
}
$count = count($_FILES['images']['name']);
$success_count = 0;
for ($i = 0; $i < $count; $i++) {
// 跳过没有上传的文件(用户可能只选了3张,但input允许多选5张)
if ($_FILES['images']['error'][$i] !== 0) {
continue;
}
// 2. 验证 MIME 类型(必须是图片)
$file_type = $_FILES['images']['type'][$i];
if (!str_starts_with($file_type, 'image/')) {
echo "第 " . ($i + 1) . " 个不是图片,跳过<br>";
continue;
}
// 3. 创建按日期的目录结构:uploads/2024/03/15/
$date_dir = date('Y/m/d'); // 形如 "2024/03/15"
$full_dir = "./uploads/" . $date_dir;
// mkdir 的第三个参数 true 表示递归创建(父目录不存在时自动创建)
if (!is_dir($full_dir)) {
mkdir($full_dir, 0755, true);
}
// 4. 防止文件名冲突(同一秒内上传两张同名文件)
$original_name = $_FILES['images']['name'][$i];
$extension = pathinfo($original_name, PATHINFO_EXTENSION);
$new_name = pathinfo($original_name, PATHINFO_FILENAME)
. "_" . time() . "_" . $i . "." . $extension;
// 5. 执行搬家
$tmp_file = $_FILES['images']['tmp_name'][$i];
$new_path = $full_dir . "/" . $new_name;
if (move_uploaded_file($tmp_file, $new_path)) {
$success_count++;
echo "✓ 第 " . ($i + 1) . " 张上传成功:" . $new_path . "<br>";
}
}
echo "<hr>共上传 $success_count 张图片";
预期输出(比如现在是 2024 年 3 月 15 日,上传了 3 张图片):
✓ 第 1 张上传成功:./uploads/2024/03/15/风景_1710500000_0.jpg
✓ 第 2 张上传成功:./uploads/2024/03/15/美食_1710500000_1.png
✓ 第 3 张上传成功:./uploads/2024/03/15/自拍_1710500000_2.gif
<hr>共上传 3 张图片
一句话解释:用 date('Y/m/d') 自动生成日期目录,再给文件名加上时间戳防止冲突。
项目 3:15 分钟——带验证的简历收集工具
需求:HR 要收集简历,只接受 PDF 和 Word,大小不能超过 5MB,还要限制上传人数。
index.html(表单页面):
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>简历收集</title>
</head>
<body>
<h2>在线简历收集</h2>
<form action="resume_upload.php" method="POST" enctype="multipart/form-data">
<p>姓名:<input type="text" name="name" required></p>
<p>邮箱:<input type="email" name="email" required></p>
<p>简历(PDF/Word,不超过5MB):<input type="file" name="resume" accept=".pdf,.doc,.docx"></p>
<button type="submit">提交简历</button>
</form>
</body>
</html>
resume_upload.php(处理逻辑):
<?php
/**
* 简历收集工具
* 只接受 PDF/Word,大小限制 5MB
*/
$max_size = 5 * 1024 * 1024; // 5MB = 5 * 1024 * 1024 字节
$allowed_types = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx
];
// 1. 基础验证
if (!isset($_FILES['resume'])) {
die("请选择简历文件");
}
// 2. 检查文件大小
if ($_FILES['resume']['size'] > $max_size) {
die("文件太大,请压缩后重新上传(限制5MB)");
}
// 3. 检查文件类型
$file_type = $_FILES['resume']['type'];
if (!in_array($file_type, $allowed_types)) {
die("只接受 PDF 或 Word 文档");
}
// 4. 提取用户信息用于命名
$applicant_name = htmlspecialchars($_POST['name']);
$applicant_email = htmlspecialchars($_POST['email']);
$original_name = $_FILES['resume']['name'];
$extension = pathinfo($original_name, PATHINFO_EXTENSION);
// 5. 按「姓名_邮箱」命名文件,便于 HR 识别
// 邮箱里的 @ 符号换成 _at_,避免文件名问题
$safe_email = str_replace(['@', '.'], '_at_', $applicant_email);
$new_filename = $applicant_name . "_" . $safe_email . "." . $extension;
$new_path = "./resumes/" . $new_filename;
// 6. 创建目录(如果不存在)
if (!is_dir("./resumes")) {
mkdir("./resumes", 0755, true);
}
// 7. 执行搬家
if (move_uploaded_file($_FILES['resume']['tmp_name'], $new_path)) {
echo "<h2>简历提交成功!</h2>";
echo "<p>姓名:$applicant_name</p>";
echo "<p>邮箱:$applicant_email</p>";
echo "<p>文件名:$new_filename</p>";
echo '<p><a href="index.html">返回继续提交</a></p>';
} else {
echo "上传失败,请重试";
}
预期输出(用户「张三」用邮箱「hr@example.com」提交简历后):
<h2>简历提交成功!</h2>
<p>姓名:张三</p>
<p>邮箱:hr@example.com</p>
<p>文件名:张三_hr_at_example_at_com.pdf</p>
一句话解释:用用户的真实信息命名文件,HR 一看文件名就知道是谁的简历,不用再打开看了。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:没给 uploads 目录写权限
<?php
// ❌ 错误做法:直接 move_uploaded_file,但目录没有写权限
move_uploaded_file($_FILES['avatar']['tmp_name'], "./uploads/avatar.jpg");
// 可能报错:Permission denied
<?php
// ✅ 正确做法:先确认目录存在且可写
$upload_dir = "./uploads";
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true); // 创建目录
}
// 或者手动给目录设置权限:chmod 755 uploads/
一句话解释:上传前先确保「快递柜」的门是开着的。
坑 2:只检查文件扩展名
<?php
// ❌ 危险做法:只检查扩展名,坏人可以传 "病毒.jpg.php"
$extension = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
if ($extension === 'jpg') {
// 但文件内容可能是 PHP 代码!
}
<?php
// ✅ 正确做法:检查真实的 MIME 类型
$finfo = new finfo(FILEINFO_MIME_TYPE);
$real_type = $finfo->file($_FILES['file']['tmp_name']);
if (in_array($real_type, ['image/jpeg', 'image/png'])) {
// 真的是图片
}
一句话解释:看扩展名就像看名字认人,不靠谱;看 MIME 类型就像验身份证,才是真身份。
坑 3:没限制文件大小
<?php
// ❌ 危险做法:没限制,用户可能传一个 10GB 的文件把你硬盘塞满
if (move_uploaded_file(...)) { ... }
<?php
// ✅ 正确做法:在 HTML 和 PHP 两端都限制
// HTML 端:<input type="file" name="avatar"> // 不够,还要加:
?>
<!-- HTML 端加这个 -->
<input type="hidden" name="MAX_FILE_SIZE" value="2097152"> <!-- 2MB -->
<?php
// PHP 端也要检查
if ($_FILES['avatar']['size'] > 2 * 1024 * 1024) {
die("文件太大了!");
}
一句话解释:前后端都要限制,就像安检的 X 光机和人工复核,缺一不可。
坑 4:文件名没处理直接用
<?php
// ❌ 危险做法:直接用用户上传的文件名,可能包含 ../ 这种路径穿越
$new_path = "./uploads/" . $_FILES['avatar']['name'];
// 用户可能上传名为 "../../../etc/passwd" 的文件
<?php
// ✅ 正确做法:用 basename() 去掉路径,只留文件名
$safe_name = basename($_FILES['avatar']['name']);
$new_path = "./uploads/" . $safe_name;
// 或者干脆自己生成一个随机文件名
$new_name = uniqid() . "." . pathinfo($safe_name, PATHINFO_EXTENSION);
$new_path = "./uploads/" . $new_name;
一句话解释:用户的文件名可能是陷阱,自己生成文件名最安全。
坑 5:多文件上传时没检查数组下标
<?php
// ❌ 错误做法:当上传 0 个文件时,$_FILES['pictures']['name'] 可能不是数组
foreach ($_FILES['pictures']['name'] as $name) { // 报错!
<?php
// ✅ 正确做法:先检查是不是数组
if (is_array($_FILES['pictures']['name'])) {
foreach ($_FILES['pictures']['name'] as $index => $name) {
// 处理每个文件
}
} else {
// 单文件上传的情况
}
性能小贴士:大批量上传用分批处理
如果用户一次上传 100+ 个文件,可以考虑:
<?php
// 把文件信息先存到数据库,不立即移动
// 之后用 cron 任务批量处理,避免 HTTP 请求超时
或者用异步处理框架(如 RabbitMQ)来消化这些文件。
调试技巧:用 print_r 打印 $_FILES
文件上传出问题?先打印看看:
<?php
echo "<pre>";
print_r($_FILES);
echo "</pre>";
有时候是 HTML 表单的 enctype="multipart/form-data" 漏写了,有时候是 name 写错了——打印出来一眼就能看出来。
✏️ 练习题
练习 1(2 分钟):改名字
- 输入:用户上传 my_photo.jpg
- 预期输出:文件保存为 my_photo.jpg
- 提示:直接改 upload.php 里 new_path 的拼接方式
练习 2(2 分钟):加个判断
- 输入:上传一个 .txt 文件
- 预期输出:提示「只允许上传图片」
- 提示:在项目 1 的代码里加一个 MIME 类型判断
练习 3(3 分钟):换个目录
- 输入:把上传的文件存到 ../public/uploads/ 而不是 ./uploads/
- 预期输出:文件出现在上级目录的 public/uploads 下
- 提示:改 new_path 的路径即可
练习 4(3 分钟):限制文件大小
- 输入:上传一个 6MB 的文件
- 预期输出:提示「文件太大,限制 2MB」
- 提示:在项目 1 里加文件大小判断
练习 5(5 分钟):分析报错
- 输入:用户报错「move_uploaded_file 失败」
- 预期输出:你能说出至少 2 个可能的原因
- 提示:检查目录权限、文件名是否安全、临时文件是否还在
📚 作业:做一个「图片收集器 Plus」
需求描述:做一个带管理后台的图片收集工具,用户可以上传图片,后台能看到所有图片列表。
功能点:
1. 用户上传图片,自动按日期分类
2. 上传成功后显示图片预览
3. 后台页面列出所有已上传图片,支持按日期筛选
加分项:
1. 支持删除图片功能
2. 显示文件大小和上传时间
验收标准:
- 能跑起来
- 上传后文件确实出现在 uploads 目录
- 后台能看到图片列表
提交方式:评论区贴代码或 GitHub 链接
📚 总结
这章我们学了 3 个核心点:
- $_FILES:PHP 提供的文件上传「快递单查询系统」
- move_uploaded_file:把文件从临时目录「搬家」到正式目录
- MIME 验证:验明文件「身份证」,防止假文件混进来
延伸资源:
- PHP 官方文档:文件上传处理
- 《PHP 核心技术》—— 文件操作章节
- 视频:B 站搜索「PHP 文件上传实战」
你在做项目时用过文件上传吗?遇到过什么坑?评论区聊聊,老粉优先回复!
下一章我们要解决一个很常见的需求:怎么在用户注册时发一封验证邮件,或者找回密码时发一个验证码? 敬请期待!

评论(0)