
PHP在线教程:用表单和数据库搭建一个简单留言板
留言板是学习 PHP 表单处理和数据库操作的实用小项目:访客填写昵称与留言,服务器验证数据后保存到数据库,页面再读取并显示留言。这个过程涵盖了常见的 Web 开发步骤,也能帮助理解为什么“把表单内容写进数据库”还远远不够——输入需要验证,SQL 需要参数化,输出需要转义,提交过程还需要考虑跨站请求伪造和重复提交。
本文使用 PHP 8.1 或更高版本、PDO_SQLITE 和 SQLite。示例可以作为本地练习或低流量内部工具的起点;它不是完整的社区系统,也没有实现用户账号、内容审核、垃圾信息识别或高并发优化。上线前应根据实际场景补充相应措施。
一、适用场景与项目结构
SQLite 不需要单独运行数据库服务,数据保存在一个文件中,适合本地学习、原型验证、单机小型应用,以及写入量较低的内部工具。若应用需要多台 Web 服务器共同读写、频繁并发写入、复杂权限管理或独立备份恢复,应评估 MySQL、PostgreSQL 等数据库,而不是简单地把 SQLite 文件放到共享磁盘上。
建议把数据库文件放在网站公开目录之外,避免访客通过 URL 直接下载。例如,项目结构如下:
留言板项目/
├── public/
│ └── index.php
└── var/
└── (程序首次运行后生成 guestbook.sqlite)
将下面的程序保存为 public/index.php。PHP 进程需要能够在 var 目录中创建和写入文件。生产环境中,应由部署人员创建该目录并配置最小必要的文件权限;不要为了省事把整个项目设为所有用户都可写。若使用 Apache、Nginx 或 PHP 内置服务器,网站根目录应指向 public,而不是项目根目录。
二、可运行的留言板代码
运行环境需要启用 PDO 和 PDO_SQLITE 扩展;本例还使用 mbstring 处理 UTF-8 字符长度。可通过 php -m 查看当前 PHP 命令行环境加载的扩展。命令行 PHP 与 Web 服务器使用的 PHP 配置可能不同,应分别确认。
<?php
declare(strict_types=1);
// 必须在 session_start() 之前设置会话 Cookie 参数。
$isHttps = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
'httponly' => true,
'secure' => $isHttps,
'samesite' => 'Lax',
]);
session_start();
header('Content-Type: text/html; charset=UTF-8');
function escapeHtml(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
$projectDir = dirname(__DIR__);
$dataDir = $projectDir . '/var';
$dbFile = $dataDir . '/guestbook.sqlite';
try {
if (!is_dir($dataDir)) {
throw new RuntimeException('数据目录不存在,请先创建 var 目录。');
}
$pdo = new PDO('sqlite:' . $dbFile, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
)'
);
} catch (Throwable $e) {
// 详细错误写入服务器日志,避免把路径和配置暴露给访客。
error_log($e->getMessage());
http_response_code(500);
exit('留言板暂时无法使用,请联系管理员。');
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$errors = [];
$name = '';
$content = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submittedToken = $_POST['csrf_token'] ?? '';
if (
!is_string($submittedToken)
|| !hash_equals($_SESSION['csrf_token'], $submittedToken)
) {
$errors[] = '表单已过期或请求无效,请刷新页面后重试。';
}
$rawName = $_POST['name'] ?? '';
$rawContent = $_POST['content'] ?? '';
if (!is_string($rawName) || !is_string($rawContent)) {
$errors[] = '表单数据格式不正确。';
} else {
$name = trim($rawName);
$content = trim(str_replace(
["\\r\\n", "\\r"],
"\\n",
$rawContent
));
if (!mb_check_encoding($name, 'UTF-8')
|| !mb_check_encoding($content, 'UTF-8')) {
$errors[] = '请输入有效的 UTF-8 文本。';
} else {
if ($name === '') {
$errors[] = '昵称不能为空。';
} elseif (mb_strlen($name, 'UTF-8') > 40) {
$errors[] = '昵称不能超过 40 个字符。';
}
if ($content === '') {
$errors[] = '留言内容不能为空。';
} elseif (mb_strlen($content, 'UTF-8') > 2000) {
$errors[] = '留言内容不能超过 2000 个字符。';
}
}
}
if ($errors === []) {
try {
$statement = $pdo->prepare(
'INSERT INTO messages (name, content, created_at)
VALUES (:name, :content, :created_at)'
);
$statement->execute([
':name' => $name,
':content' => $content,
':created_at' => gmdate('Y-m-d H:i:s'),
]);
// 轮换令牌,再用 POST-Redirect-GET 避免刷新时重复提交。
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: index.php', true, 303);
exit;
} catch (Throwable $e) {
error_log($e->getMessage());
$errors[] = '保存失败,请稍后重试。';
}
}
}
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
$page = ($page !== false && $page !== null) ? max(1, $page) : 1;
$perPage = 20;
$offset = ($page - 1) * $perPage;
try {
$total = (int) $pdo->query(
'SELECT COUNT(*) FROM messages'
)->fetchColumn();
$statement = $pdo->prepare(
'SELECT id, name, content, created_at
FROM messages
ORDER BY id DESC
LIMIT :limit OFFSET :offset'
);
$statement->bindValue(':limit', $perPage, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
$statement->execute();
$messages = $statement->fetchAll();
} catch (Throwable $e) {
error_log($e->getMessage());
http_response_code(500);
exit('读取留言失败,请联系管理员。');
}
$totalPages = max(1, (int) ceil($total / $perPage));
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>简单留言板</title>
<style>
body { max-width: 760px; margin: 2rem auto; padding: 0 1rem;
font: 16px/1.6 sans-serif; }
label { display: block; margin-top: 1rem; }
input, textarea { box-sizing: border-box; width: 100%; padding: .6rem; }
textarea { min-height: 8rem; }
.error { color: #a00; }
.message { border-top: 1px solid #ddd; padding: 1rem 0; }
.meta { color: #666; font-size: .9rem; }
.content { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>简单留言板</h1>
<?php if ($errors !== []): ?>
<ul class="error">
<?php foreach ($errors as $error): ?>
<li><?= escapeHtml($error) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post" action="index.php">
<input type="hidden" name="csrf_token"
value="<?= escapeHtml($_SESSION['csrf_token']) ?>">
<label for="name">昵称(最多 40 个字符)</label>
<input id="name" name="name" maxlength="40" required
value="<?= escapeHtml($name) ?>">
<label for="content">留言(最多 2000 个字符)</label>
<textarea id="content" name="content" maxlength="2000"
required><?= escapeHtml($content) ?></textarea>
<button type="submit">提交留言</button>
</form>
<h2>最新留言</h2>
<?php if ($messages === []): ?>
<p>还没有留言。</p>
<?php endif; ?>
<?php foreach ($messages as $message): ?>
<article class="message">
<p class="meta">
<?= escapeHtml($message['name']) ?>
· <?= escapeHtml($message['created_at']) ?> UTC
</p>
<div class="content"><?=
escapeHtml($message['content'])
?></div>
</article>
<?php endforeach; ?>
<nav aria-label="留言分页">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>">上一页</a>
<?php endif; ?>
第 <?= $page ?> / <?= $totalPages ?> 页
<?php if ($page < $totalPages): ?>
<a href="?page=<?= $page + 1 ?>">下一页</a>
<?php endif; ?>
</nav>
</body>
</html>
在项目根目录创建 var 目录后,可用 PHP 内置服务器进行本地测试:
mkdir -p var
php -S 127.0.0.1:8000 -t public
然后访问 http://127.0.0.1:8000/。内置服务器仅适合本地开发和测试,不应直接作为公开生产服务使用。首次访问时,程序会创建数据表和 SQLite 文件;若目录不可写,页面会返回通用错误,具体原因应查看 PHP 错误日志。
三、代码中的关键边界
1. 表单校验不等于安全过滤
浏览器中的 required 和 maxlength 有助于改善填写体验,但它们可以被绕过,不能代替服务器验证。代码在服务端检查字段类型、UTF-8 编码、空值和字符数。对多字节文本使用 mb_strlen,避免把中文字符误按字节计数;因此运行环境需要 mbstring。
字符数限制也不等于完整的请求体大小限制。服务器仍需配置合理的请求体上限,并结合应用的流量和资源预算评估。示例限制的是昵称与留言内容,不代表 HTTP 请求中的其他字段、请求头或整个请求都受到同样的字节级限制。若扩展成允许上传图片的表单,还需要单独验证文件大小、类型、存储路径和访问权限。
2. SQL 参数化保护的是 SQL 结构
插入数据时使用 PDO 预处理语句,并通过占位符绑定昵称、内容和时间。不要将表单值直接拼接进 SQL 字符串;参数化查询用于把数据和 SQL 语句结构分开,防止这类数据被当作 SQL 语法执行。
参数化查询不能替代输出转义,也不能让所有动态 SQL 都自动安全。例如,排序字段名或表名通常不能按普通值绑定;若将来要让用户选择排序方式,应使用服务器端的固定白名单映射,而不是把用户输入直接拼进 SQL。
3. 输出转义要放在展示时做
留言内容被保存为普通文本,显示时通过 htmlspecialchars 转义。这样,用户提交的 <script> 会作为文字显示,而不会成为页面中的可执行标签。转义针对的是输出上下文:本例输出到 HTML 文本和带引号的 HTML 属性,因此使用 ENT_QUOTES 和 UTF-8 编码。
不要因为“写入数据库前已经过滤过”就省略展示时转义。数据库中的内容可能来自其他程序、历史版本或人工修改,仍应在每个输出位置依据上下文处理。如果以后把数据放进 JavaScript、CSS 或 URL,不能机械地把 HTML 转义当作通用解法,应使用适合对应上下文的安全编码方式。
white-space: pre-wrap 只负责保留换行并调整显示,不会替代 HTML 转义。示例不把留言当作 Markdown 或 HTML 渲染。如果产品确实需要富文本,应采用经过审查的 HTML 清理方案,并明确允许的标签、属性和链接协议。
4. CSRF 令牌与会话 Cookie 的作用
页面为会话生成随机令牌,提交时用 hash_equals 比较。它用于降低用户在已登录或已建立会话的浏览器中,被其他网站诱导提交表单的风险。令牌不是用户身份认证手段,也不能防止垃圾信息、自动化机器人或恶意用户自行访问表单。
会话 Cookie 设置了 HttpOnly、SameSite=Lax,并在 PHP 识别到 HTTPS 时设置 Secure。正式部署应使用 HTTPS。若站点位于反向代理之后,PHP 对 HTTPS 状态的判断需要按可信代理环境正确配置;不要不加验证地信任任意客户端提供的转发请求头。会话配置也不取代站点整体的身份验证和授权设计。
5. PRG、分页与数据增长
提交成功后,代码返回 HTTP 303 并跳转到列表页,这是 POST-Redirect-GET 模式,可减少用户刷新页面时重复提交同一条留言的概率。它不是去重机制:网络重试、并发请求或其他客户端仍可能产生重复数据。如业务上要求幂等,应另外设计请求标识或去重规则。
列表按自增 ID 倒序排列,每页读取 20 条,避免一次取出全部留言。页码经整数验证并限制为至少 1,SQL 中的分页参数以整数类型绑定。随着数据持续增长,偏移量分页在深页上可能变慢;高数据量场景可考虑基于最后一条记录 ID 的游标分页。当前示例没有删除或清理历史数据的功能,部署者需要按保存期限和隐私要求制定数据管理策略。
四、常见错误与排查方法
- 提示驱动程序不存在:检查 Web 服务器使用的 PHP 是否启用了 PDO_SQLITE。即使命令行的
php -m显示已启用,Web 环境也可能使用另一套 PHP 配置。 - 无法打开数据库或创建文件:确认
var目录存在,并且运行 PHP 的系统用户有必要的读写权限。SQLite 可能还需要在同一目录创建临时文件;不要只检查数据库文件本身的权限。 - 字符数函数未定义:启用 mbstring 扩展,并重启或重新加载对应的 PHP 服务。
- 页面报表单格式错误:不要假定
$_POST中的值永远是字符串。示例显式检查类型,以应对异常构造的请求。 - 中文显示异常:检查页面响应是否为 UTF-8,文件是否以 UTF-8 保存,数据库和连接设置是否一致。示例在响应头与文档中都声明了 UTF-8。
- 网页能显示,命令行却报错,或相反:分别检查 PHP 版本、扩展、运行用户、工作目录和错误日志;命令行与 Web 服务经常使用不同配置。
- 提交后刷新仍出现重复留言:确认成功路径确实执行了 303 跳转。PRG 能减少浏览器刷新造成的重复提交,但不能替代严格的幂等控制。
五、上线前还应评估什么
这个示例没有限流、验证码、内容审核、封禁机制或后台管理。公开开放的留言板容易收到广告、恶意链接和自动化提交。应根据风险增加请求频率限制、内容审核与举报处理,并对管理操作做身份验证和授权。若加验证码,也不要把它视为唯一的反滥用措施。
此外,SQLite 数据库文件应纳入备份计划,并定期验证备份是否可恢复;备份文件同样可能含有个人信息,需要设置访问权限和保留期限。生产错误日志应妥善管理,页面只给用户返回通用提示,避免泄露服务器路径、SQL 细节或配置。若留言会保存或公开展示个人信息,还应结合所在地区的法律要求,说明收集目的、保存期限和删除渠道。
完成这些基础步骤后,留言板就不只是“能把表单写进数据库”的演示,而是一个边界清晰、便于继续扩展的小型 Web 应用。后续可以逐步加入编辑与删除、管理员登录、审核状态、搜索和部署自动化;每增加一种能力,都应重新检查权限、输入验证、数据库查询和输出上下文。









