PHP 实战项目
学了 16 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——留言板、REST API、文件上传管理,分别覆盖 PHP 三大主力场景:Web 表单 + 数据库、接口服务、文件处理。每个项目都从需求出发,给出完整代码、运行结果和要点讲解。建议你亲手跑一遍(php -S localhost:8000 起内置服务器),再改改需求,这才是真正"上手"。
实战 1:留言板(PDO + CRUD + XSS 防护)
需求:做一个留言板——访客填昵称和内容提交,所有留言按时间倒序展示。数据用 SQLite 持久化(零配置),必须防住 SQL 注入和 XSS 攻击。
涉及知识:PDO 连接与预处理语句、SQLite 建表、$_POST 表单接收、htmlspecialchars 输出转义、PRG(Post-Redirect-Get)防重复提交。无需第三方依赖。
<?php
// guestbook.php — 留言板:展示留言 + 提交新留言
// 用 SQLite 存储(零配置),PDO 预处理防 SQL 注入,htmlspecialchars 防 XSS
// 1. 连接数据库(SQLite 单文件);建表(若不存在)
$pdo = new PDO('sqlite:guestbook.db');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
content TEXT NOT NULL,
created TEXT DEFAULT CURRENT_TIMESTAMP
)");
// 2. 处理 POST 提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($name !== '' && $content !== '') {
// 预处理语句:参数化绑定,彻底防 SQL 注入
$stmt = $pdo->prepare(
'INSERT INTO messages (name, content) VALUES (:name, :content)'
);
$stmt->execute([':name' => $name, ':content' => $content]);
// PRG 模式:POST 后重定向到 GET,避免刷新重复提交
header('Location: guestbook.php');
exit;
}
$error = '昵称和内容都不能为空';
}
// 3. 读取全部留言(按时间倒序)
$rows = $pdo->query('SELECT * FROM messages ORDER BY id DESC')
->fetchAll(PDO::FETCH_ASSOC);
// e() 是 htmlspecialchars 的简写:输出到 HTML 时转义,防 XSS
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html><meta charset="utf-8">
<h2>留言板</h2>
<?php if (!empty($error)): ?>
<p style="color:red"><?= e($error) ?></p>
<?php endif; ?>
<form method="post">
<input name="name" placeholder="昵称" required>
<textarea name="content" placeholder="说点什么" required></textarea>
<button type="submit">发表</button>
</form>
<?php foreach ($rows as $row): ?>
<div>
<strong><?= e($row['name']) ?></strong>
<small><?= e($row['created']) ?></small>
<p><?= nl2br(e($row['content'])) ?></p>
</div>
<?php endforeach; ?>
<!-- 提交后页面会多出一条留言;XSS 测试:输入 <script>alert(1)</script> 会被原样转义成文本 -->要点讲解:
PDO::prepare:预处理 + 参数绑定是唯一正确的防 SQL 注入姿势。永远不要用字符串拼接构造 SQL("... WHERE name='" . $name . "'"是教科书级漏洞)。- 命名参数:
:name/:content占位,execute([':name' => $name])绑定——比问号参数可读,支持数组分发。 htmlspecialchars:输出到 HTML 时把<>&"'转成实体,防 XSS。e()简写让每次输出都自觉用上——这是 Web 安全的第一道闸。- PRG 模式:POST 处理完
header('Location: ...')跳到 GET 页面,这样用户刷新不会重复提交表单。 nl2br:把换行符\n转成<br>,保留留言的换行排版;注意先e()转义再nl2br。- 错误模式:
ATTR_ERRMODE => ERRMODE_EXCEPTION让 SQL 错误抛异常,便于 try/catch;默认是静默失败,排查困难。
实战 2:REST API(header json + 路由 + $_GET/$_POST)
需求:不用任何框架,纯 PHP 写一个商品 REST API——GET /products 列全部、GET /products?id=3 取单条、POST /products 新增。所有响应都是 JSON。
涉及知识:header('Content-Type: application/json')、$_SERVER['PATH_INFO'] 路由分发、php://input 读 body、json_encode/decode、HTTP 状态码。
<?php
// api.php — 极简 REST API:基于 PATH_INFO 路由分发 + JSON 响应
// 用法:GET /api.php/products 列出全部
// GET /api.php/products?id=3 取单条
// POST /api.php/products 新增(body 放 JSON)
header('Content-Type: application/json; charset=utf-8');
// 内存数据(真实项目从数据库取)
$products = [
['id' => 1, 'name' => '苹果', 'price' => 5.0],
['id' => 2, 'name' => '香蕉', 'price' => 3.0],
['id' => 3, 'name' => '橙子', 'price' => 4.0],
];
// 取 PATH_INFO 作为路由:/api.php/products → 'products'
$path = $_SERVER['PATH_INFO'] ?? '/';
$method = $_SERVER['REQUEST_METHOD'];
// json_out 统一返回 JSON
function json_out($data, int $code = 200): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// 路由分发
if ($path === '/products') {
if ($method === 'GET') {
// 取单条 ?id=3
if (isset($_GET['id'])) {
$id = (int)$_GET['id'];
foreach ($products as $p) {
if ($p['id'] === $id) json_out($p);
}
json_out(['error' => "未找到 id={$id}"], 404); // 双引号里 {$id} 自动解析
}
// 否则列全部
json_out($products);
}
if ($method === 'POST') {
// 从 body 读 JSON
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['name'])) {
json_out(['error' => 'name 必填'], 400);
}
$new = [
'id' => count($products) + 1,
'name' => $body['name'],
'price' => (float)($body['price'] ?? 0),
];
json_out($new, 201);
}
json_out(['error' => '不支持的方法'], 405);
}
// 兜底:未匹配的路由
json_out(['error' => '路由不存在'], 404);
// 测试(另开终端):
// $ curl 'http://localhost:8000/api.php/products'
// [{"id":1,"name":"苹果","price":5},{"id":2,"name":"香蕉","price":3},{"id":3,"name":"橙子","price":4}]
// $ curl 'http://localhost:8000/api.php/products?id=2'
// {"id":2,"name":"香蕉","price":3}
// $ curl -X POST http://localhost:8000/api.php/products \
// -H 'Content-Type: application/json' -d '{"name":"葡萄","price":8}'
// {"id":4,"name":"葡萄","price":8}要点讲解:
- JSON 响应头:
header('Content-Type: application/json; charset=utf-8')必须在任何输出之前设置,否则浏览器/客户端会按 HTML 解析。 JSON_UNESCAPED_UNICODE:默认json_encode会把中文转成\uXXXX,加这个 flag 让中文原样输出,调试和前端都更友好。- 路由:用
$_SERVER['PATH_INFO']取路径(/api.php/products → products),按 method + path 分发——这是所有 PHP 框架路由的雏形(Slim、Laravel 都基于此)。 php://input:读原始请求 body,JSON API 必须用它($_POST只能解析表单数据);json_decode(..., true)返回关联数组。- HTTP 状态码:
http_response_code(404)/405/201让客户端能区分"成功 / 失败 / 不存在",REST 必须正确。 - 进阶方向:接数据库、加 JWT 鉴权、用 Composer 引入 league/route 或 Slim、加 OpenAPI 文档、限流。
实战 3:文件上传管理($_FILES + 验证 + 安全)
需求:做一个文件上传页面——用户选文件提交,服务端做类型检查、大小限制、随机文件名,安全地存到 uploads/ 目录。允许 JPG/PNG/GIF/PDF,最大 2 MB。
涉及知识:$_FILES 超全局数组、enctype="multipart/form-data" 表单、finfo 探测真实 MIME、move_uploaded_file、随机文件名防覆盖。
<?php
// upload.php — 文件上传:限制类型/大小、生成随机文件名、移到上传目录
// php.ini 配置:file_uploads=On, upload_max_filesize=2M, post_max_size=8M
const UPLOAD_DIR = __DIR__ . '/uploads';
const MAX_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
// 确保目录存在
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0755, true);
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_FILES['file'])) {
$f = $_FILES['file'];
// 1. 检查错误码($_FILES['file']['error'])
if ($f['error'] !== UPLOAD_ERR_OK) {
$message = "上传错误码: {$f['error']}(常见:1=超 php.ini 限制,4=未选文件)";
}
// 2. 检查大小
elseif ($f['size'] > MAX_SIZE) {
$message = '文件太大,限制 2 MB 以内';
}
// 3. 检查真实 MIME(用 finfo 探测文件头,不信任 $_FILES['type'])
else {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $f['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, ALLOWED, true)) {
$message = "不允许的类型: {$mime}";
} else {
// 4. 生成随机文件名,避免覆盖与路径遍历攻击
$ext = pathinfo($f['name'], PATHINFO_EXTENSION);
$safeName = bin2hex(random_bytes(8)) . '.' . $ext;
$dest = UPLOAD_DIR . DIRECTORY_SEPARATOR . $safeName;
// 5. is_uploaded_file + move_uploaded_file:只处理真上传的文件
if (move_uploaded_file($f['tmp_name'], $dest)) {
$message = "上传成功: {$safeName}";
} else {
$message = '移动文件失败';
}
}
}
}
?>
<!DOCTYPE html><meta charset="utf-8">
<h2>文件上传</h2>
<?php if ($message): ?><p><?= e($message) ?></p><?php endif; ?>
<!-- enctype="multipart/form-data" 是上传表单必须的 -->
<form method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">上传</button>
</form>
<p>允许:JPG / PNG / GIF / PDF,最大 2 MB</p>要点讲解:
- 不要信任
$_FILES['file']['type']:它是客户端给的,可伪造!必须用finfo_file()读文件头探测真实 MIME 类型——这是文件上传安全的核心。 $_FILES['file']['error']:检查错误码,UPLOAD_ERR_OK (=0)才是真上传成功;1表示超 php.ini 的upload_max_filesize。- 随机文件名:
bin2hex(random_bytes(8))生成 16 位十六进制名——既防覆盖,也防恶意文件名(../../etc/passwd这种路径遍历攻击)。 move_uploaded_file:这是专门处理 HTTP 上传文件的函数,内部会检查is_uploaded_file,比copy/rename安全——攻击者无法让它移动任意系统文件。__DIR__:当前脚本所在目录的绝对路径,用它拼路径可移植(不依赖 cwd);DIRECTORY_SEPARATOR跨平台兼容。- 深度安全:上传目录建议配置 Nginx/Apache 不执行 PHP(
php_flag engine off),否则攻击者上传伪装成图片的 PHP 文件就能拿下服务器。
小结
三个项目分别对应 PHP 三大主力场景:表单 + 数据库(留言板)、API 服务(REST)、文件处理(上传)。它们都反复强调了 PHP Web 开发的两条生命线:预处理语句防 SQL 注入、htmlspecialchars 防 XSS——这两条做扎实,你的站已经挡掉 80% 的攻击。建议至少把留言板改造成自己的版本(加分页、加管理员审核、加头像上传),做过一遍比读十遍更扎实。
恭喜!到这里 PHP 系列就真正完结了。接下来挑一个方向深入——大型框架(Laravel / Symfony)、API 服务(Slim + Doctrine)、或 WordPress 二次开发——边做边学,进步最快。
← 上一篇 PHP 练习题
返回 PHP 教程目录