
PHP实例:从表单提交到数据库保存的完整流程
一个常见的 PHP 功能,是让用户填写表单,再把内容保存到数据库。例如留言、联系请求、预约信息或简单的反馈记录。看起来只需要“接收几个字段,然后执行 INSERT”,实际开发还要处理请求方法、输入类型、校验规则、字符编码、数据库错误以及跨站请求伪造等问题。
本文使用 PHP 8、MySQL 和 PDO,完成一个可运行的留言表单示例。页面通过 GET 显示表单,通过 POST 接收数据;服务端校验后使用预处理语句写入数据库;保存成功后重定向,避免刷新页面时重复提交。示例适用于低至中等复杂度的普通表单,不包含用户登录、文件上传、邮件发送等功能。
一、先明确数据流和适用场景
完整流程可以拆成以下几步:
- 浏览器请求页面,服务器返回表单。
- 用户提交表单,浏览器发送 POST 请求。
- PHP 检查请求方法、CSRF 令牌和字段类型。
- PHP 按业务规则校验字段,并限制可接受的长度。
- PDO 使用预处理语句执行 INSERT。
- 写入成功后返回重定向;失败时记录服务器端错误并向用户显示通用提示。
这种模式适合留言、联系人信息、反馈内容等以单条记录写入为主的场景。如果表单会同时修改多张表、扣减库存或执行多项必须成败一致的操作,通常还要使用数据库事务。若涉及信用卡信息、健康信息、密码或其他敏感数据,则不能只照搬本例:需要另外进行数据最小化、访问控制、加密、审计和保留期限设计。
二、创建数据库和表
先创建数据库和留言表。以下 SQL 假设 MySQL 5.7 及以上版本,字符集使用 utf8mb4,以支持较完整的 Unicode 字符。
CREATE DATABASE form_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE form_demo;
CREATE TABLE contact_messages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(254) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
VARCHAR(100)、VARCHAR(254) 是数据库字段的长度上限,不会自动替代 PHP 校验。应用层应在写入前给出清楚的错误提示,数据库约束则是最后一道数据完整性防线。这里的邮件地址长度上限取 254 个字符,是常见的应用校验取值;邮件系统的实际规则和业务需要可能不同,不能仅凭这个数字判断某个地址一定可投递。
建表账号与网站运行账号最好分开。运行网站的数据库账号只授予该应用需要的权限,例如对目标表执行 INSERT;不要在 PHP 配置中使用 MySQL 的 root 账号。实际部署还应通过数据库管理工具或迁移脚本执行建表语句,而不是让普通网页请求自动创建表。
三、准备连接配置
示例将连接信息放在环境变量中。配置和 PHP 页面可以放在网站公开目录之外;不要把真实密码提交到公开代码仓库。下面的环境变量名称仅作示例,运行环境需要实际设置它们。
DB_DSN="mysql:host=127.0.0.1;port=3306;dbname=form_demo;charset=utf8mb4"
DB_USER="form_app"
DB_PASSWORD="请替换为真实密码"
如果本机开发环境没有设置环境变量,也可以在本地的私有配置文件中读取配置,但不要把包含生产密码的文件放到可公开下载的位置。PDO 的 DSN 中显式指定 charset=utf8mb4,有助于避免连接字符集与数据表字符集不一致。
四、完整示例:一个 PHP 文件处理表单与写入
创建 index.php,并确保 PHP 安装了 PDO MySQL 驱动(通常名为 pdo_mysql)和 mbstring 扩展。把下面代码中的数据库配置按实际环境设置后,即可在本机运行。
<?php
declare(strict_types=1);
/*
* 生产环境应通过 HTTPS 提供页面。
* secure 为 true 时,浏览器只会在 HTTPS 连接中发送会话 Cookie。
*/
$isHttps = isset($_SERVER['HTTPS'])
&& $_SERVER['HTTPS'] !== ''
&& $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
'httponly' => true,
'secure' => $isHttps,
'samesite' => 'Lax',
]);
session_start();
function escapeHtml(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
function postString(string $key): ?string
{
if (!isset($_POST[$key]) || !is_string($_POST[$key])) {
return null;
}
return $_POST[$key];
}
$errors = [];
$name = '';
$email = '';
$message = '';
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$success = isset($_GET['sent']) && $_GET['sent'] === '1';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nameInput = postString('name');
$emailInput = postString('email');
$messageInput = postString('message');
$submittedToken = postString('csrf_token');
$name = trim($nameInput ?? '');
$email = trim($emailInput ?? '');
$message = trim($messageInput ?? '');
if ($submittedToken === null
|| !hash_equals($_SESSION['csrf_token'], $submittedToken)) {
$errors[] = '表单已过期或校验失败,请重新提交。';
}
if ($nameInput === null) {
$errors[] = '姓名字段格式不正确。';
} elseif ($name === '') {
$errors[] = '请填写姓名。';
} elseif (mb_strlen($name, 'UTF-8') > 100) {
$errors[] = '姓名不能超过 100 个字符。';
}
if ($emailInput === null) {
$errors[] = '邮箱字段格式不正确。';
} elseif ($email === '') {
$errors[] = '请填写邮箱。';
} elseif (mb_strlen($email, 'UTF-8') > 254) {
$errors[] = '邮箱不能超过 254 个字符。';
} elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = '邮箱格式不正确。';
}
if ($messageInput === null) {
$errors[] = '留言字段格式不正确。';
} elseif ($message === '') {
$errors[] = '请填写留言内容。';
} elseif (mb_strlen($message, 'UTF-8') > 5000) {
$errors[] = '留言不能超过 5000 个字符。';
}
if ($errors === []) {
try {
$dsn = getenv('DB_DSN');
$dbUser = getenv('DB_USER');
$dbPassword = getenv('DB_PASSWORD');
if ($dsn === false || $dbUser === false || $dbPassword === false) {
throw new RuntimeException('Database configuration is missing.');
}
$pdo = new PDO($dsn, $dbUser, $dbPassword, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$statement = $pdo->prepare(
'INSERT INTO contact_messages (name, email, message)
VALUES (:name, :email, :message)'
);
$statement->execute([
':name' => $name,
':email' => $email,
':message' => $message,
]);
/*
* 一次性更新令牌,并采用 POST/Redirect/GET,
* 避免刷新成功页面时再次提交同一份表单。
*/
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: index.php?sent=1', true, 303);
exit;
} catch (Throwable $exception) {
/*
* 详细异常只写入服务器日志,不回显给浏览器。
* 生产环境不要把数据库密码或 SQL 错误展示给用户。
*/
error_log((string) $exception);
$errors[] = '暂时无法保存,请稍后再试。';
}
}
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>联系表单</title>
</head>
<body>
<main>
<h1>留言</h1>
<?php if ($success): ?>
<p role="status">提交成功,感谢留言。</p>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div role="alert">
<p>提交未完成,请检查以下内容:</p>
<ul>
<?php foreach ($errors as $error): ?>
<li><?= escapeHtml($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" action="index.php">
<input
type="hidden"
name="csrf_token"
value="<?= escapeHtml($_SESSION['csrf_token']) ?>"
>
<p>
<label for="name">姓名</label><br>
<input
id="name"
name="name"
type="text"
maxlength="100"
required
value="<?= escapeHtml($name) ?>"
>
</p>
<p>
<label for="email">邮箱</label><br>
<input
id="email"
name="email"
type="email"
maxlength="254"
required
value="<?= escapeHtml($email) ?>"
>
</p>
<p>
<label for="message">留言内容</label><br>
<textarea
id="message"
name="message"
maxlength="5000"
rows="8"
required
><?= escapeHtml($message) ?></textarea>
</p>
<button type="submit">提交</button>
</form>
</main>
</body>
</html>
这段代码包含表单展示和表单处理两部分。访问页面时,PHP 生成会话令牌并显示表单;提交时,程序只接受字符串类型的字段。这个类型检查很重要:攻击者可以绕开浏览器,把 name 提交成数组。直接对数组调用 trim() 或 filter_var(),可能产生警告或错误,因此不能假设请求参数一定符合表单控件的类型。
五、逐项理解关键处理
1. 浏览器校验不能代替服务器校验
required、maxlength 和 type="email" 能改善普通用户的填写体验,但这些限制在浏览器端执行,可以被关闭或绕过。攻击者也可以直接构造 HTTP 请求。因此 PHP 再次检查必填项、长度和邮箱格式,是保存前必须完成的工作。
本例用 mb_strlen() 按 UTF-8 字符计算长度,避免把一个中文字符按多个字节计数。PHP 的 strlen() 计算的是字节数,适合做字节长度检查,却不适合直接解释为用户看到的字符数。需要注意,mb_strlen() 计算的是编码字符数,不一定等于用户认知中的“字形数”;组合字符和某些表情符号可能由多个 Unicode 码点组成。如果业务限制的是显示宽度、字形数或字节数,必须先明确定义,再选用匹配的规则。
2. 参数限制由多层共同承担
代码中的 100、254 和 5000 是这个示例设定的业务限制,不是 PHP 对所有表单自动施加的固定上限,也不意味着任何应用都必须采用这些数值。可以根据实际业务调整,但 PHP 校验、HTML 属性和数据库列长度应保持一致。若希望接受超过 5000 个字符的内容,要同时检查数据表、接口限制和页面需求。
应用字段长度限制也不等于整个 HTTP 请求的大小限制。PHP 配置中的 post_max_size、Web 服务器的请求体限制和反向代理限制,都可能让请求在到达业务代码前被拒绝或截断。涉及大文本、附件或批量提交时,还应单独配置请求体上限并设计相应处理流程。文件上传另有 MIME 类型、文件大小、存储位置和病毒扫描等风险,本例没有实现文件上传。
3. PDO 预处理语句降低 SQL 注入风险
SQL 中的占位符代表值,用户输入通过 execute() 作为参数传入。不要把用户输入直接拼接进 SQL 字符串,例如将姓名连接到 INSERT 语句中。预处理语句用于安全地传递数据值,但不能把表名或列名等 SQL 标识符当成普通参数绑定;如果查询结构需要动态变化,应从服务端预先定义的白名单中选择,而不是接受用户任意输入。
PDO 的错误模式设置为异常,让连接或写入失败能够被捕获。向用户返回通用错误,详细异常通过服务器日志排查,可以减少数据库结构、连接信息和 SQL 细节泄漏的机会。日志也需要妥善管理:不要无控制地记录完整敏感内容、密码、令牌或其他秘密。
4. CSRF 令牌保护的是什么
令牌由 random_bytes() 生成,放在服务器会话和表单隐藏字段中。提交时用 hash_equals() 比对,避免使用普通字符串比较带来的时序差异。这样可以帮助服务器判断 POST 请求是否持有与当前会话匹配的令牌,降低用户已登录或浏览器携带会话 Cookie 时遭受跨站请求伪造的风险。
CSRF 令牌不是访问控制,也不能证明提交人是真人;它不能防止垃圾留言、自动化脚本、钓鱼或恶意用户本人提交内容。必要时还要采用登录权限检查、速率限制、验证码或人工审核等措施。会话 Cookie 的 HttpOnly 有助于降低脚本读取 Cookie 的风险,SameSite=Lax 提供额外的跨站请求限制;二者都不能取代 CSRF 校验。正式环境应使用 HTTPS,这样 secure 属性才会让 Cookie 仅通过 HTTPS 发送。
5. 输出转义要发生在显示的位置
数据库会保存用户提交的原始文本;显示时再使用 htmlspecialchars() 转义,避免输入内容被浏览器当作 HTML 或脚本解释。示例使用 ENT_QUOTES,同时转义单双引号,并指定 UTF-8。表单校验失败后回填字段时也做了转义,错误消息同样经过转义。
“入库时统一删掉特殊字符”不是合适的替代方案:它可能损坏正常内容,也无法适配不同输出上下文。HTML 文本、HTML 属性、JavaScript、URL 和 CSS 的编码要求并不完全一样。本文只演示 HTML 页面中的文本与属性值输出;若将数据放入脚本、URL 或其他上下文,应使用适用于对应上下文的安全编码方式。
六、本机运行与验证
确认 PHP 已启用 PDO MySQL 和 mbstring,数据库已经执行建表 SQL,运行环境也能读取前述三个数据库环境变量。在项目目录启动 PHP 内置开发服务器:
php -S 127.0.0.1:8000
然后访问 http://127.0.0.1:8000/index.php。内置服务器适合本机开发与验证,不应直接当作生产环境的 Web 服务器使用。提交正常数据后,页面会显示成功提示,表中应出现一条记录。可以再测试空字段、超过长度的内容、无效邮箱,以及含有引号或类似 HTML 标签的留言,检查校验和输出转义是否按预期工作。
如果请求报错,先检查 PHP 错误日志与数据库服务状态,再核对 DSN 中的主机、端口、库名和字符集,以及数据库用户权限。不要为了排错在公开生产页面上直接输出异常堆栈或连接密码。
七、提交成功后为什么重定向
表单成功写入后,代码返回 HTTP 303 并跳转到 GET 页面。这个“POST/Redirect/GET”流程可以避免用户刷新成功页面时,浏览器再次发送原始 POST 请求,造成意外重复插入。它并不提供绝对的幂等性:客户端可以重发请求、网络中断后重试,或用脚本重复提交。
如果业务不能接受重复记录,例如支付、订单创建或预约占位,应设计服务端幂等机制,例如为一次操作生成唯一请求标识,并在数据库中建立唯一约束;不能只依赖按钮禁用、重定向或前端提示。单次 INSERT 不需要事务;如果一次请求要写多张表,并且这些写入必须作为一个整体提交或回滚,则应在 PDO 中使用事务,并妥善处理失败后的回滚。
八、常见错误与边界
- 只靠前端校验。浏览器限制可被绕开,最终写入前必须由服务端重新校验。
- 直接拼接 SQL。用户输入应通过 PDO 预处理语句作为参数传递,不能直接拼到查询文本中。
- 只做输入校验,不做输出转义。校验字段格式不能保证内容在 HTML 页面中安全,输出时仍要按上下文转义。
- 对用户输入使用过度清洗。可能破坏正常留言。应按业务校验输入,在展示时正确编码;若业务需要允许富文本,则需使用经过维护的 HTML 白名单净化方案。
- 假设 POST 字段必定是字符串。请求可以提交数组或缺失字段,读取输入后应检查类型和存在性。
- 把数据库异常直接显示给用户。这可能暴露内部配置和结构,建议记录服务端日志并返回通用提示。
- 将客户端字段长度当成请求大小防护。
maxlength只是浏览器提示;请求体大小还受 PHP、Web 服务器和代理配置影响。 - 忽略数据库约束和权限。应用校验提供友好提示,数据表约束保护数据完整性,数据库最小权限则缩小账号泄漏后的影响范围。
- 把 CSRF 防护当成反垃圾措施。令牌解决的是请求来源验证的一类问题,不负责识别机器人、限流或审核内容。
完成这些步骤后,表单到数据库的基本闭环就建立起来了:接收请求时检查类型和规则,写入时使用预处理语句,页面输出时执行上下文适配的转义,并用合适的失败处理和重定向控制交互。真实项目还应按业务补上认证授权、幂等处理、限流、监控和数据保留策略。








