本文目录导读:

在 PHP 开发中,将表单验证和业务验证清晰地分开,是构建健壮、可维护应用的关键。
- 表单验证:关注数据格式和基本完整性(“数据是干净的吗?字段存在吗?邮箱格式对吗?”)。
- 业务验证:关注数据逻辑和当前上下文(“这个用户有权限吗?库存够吗?余额足够支付吗?用户名唯一吗?”)。
以下将详细拆解它们的概念、区别以及如何在代码中优雅地分离。
核心区别:关注点不同
| 维度 | 表单验证 | 业务验证 |
|---|---|---|
| 目的 | 确保数据格式正确,能够安全地进入系统。 | 确保数据在当前业务规则下是合法、可操作的。 |
| 判断依据 | 类型、长度、格式、必填、枚举值。 | 数据库状态、用户权限、资源依赖、会计规则、工作流规则。 |
| 变化频率 | 相对稳定,通常只在字段新增或修改时变动。 | 频繁变化,随业务迭代、促销活动、用户等级、国家法规而变。 |
| 数据来源 | 通常来自 HTTP 请求($_GET, $_POST, $_FILES)。 |
来自请求 + 数据库查询结果 + 当前系统状态。 |
| 执行顺序 | 优先执行,只有 “干净的” 数据才进入业务验证。 | 延迟执行,在表单验证通过后,进入 Service/Model 层前执行。 |
| 错误提示 | “邮箱格式不正确。” “密码长度至少 8 位。” | “该用户名已被注册。” “您的账户余额不足。” “该订单已超过可修改时限。” |
经典的错误混用案例
很多初学者会把所有逻辑揉在一起:
// 糟糕的混用示例 (在 Controller 中)
public function register(Request $request) {
// 1. 表单验证 (不错)
$name = trim($request->input('name'));
$email = filter_var($request->input('email'), FILTER_VALIDATE_EMAIL);
if (!$email) { return '邮箱格式错误'; }
if (strlen($request->input('password')) < 8) { return '密码长度不够'; }
// 2. 业务验证 (混在一起,查询数据库)
$user = User::where('email', $email)->first();
if ($user) { return '该邮箱已被注册'; } // 这是业务验证
$user = User::where('name', $name)->first();
if ($user) { return '该用户名已被注册'; } // 这是业务验证
// 3. 业务逻辑 (创建用户)
User::create([...]);
}
问题:
- 难以测试:测试邮箱格式时,还必须模拟数据库。
- 代码臃肿:Controller 逻辑过多,违反单一职责。
- 复用困难:CLI 或 API 也需要 “用户名唯一” 校验,需要复制代码。
- 可读性差:100 行验证 + 50 行业务逻辑混在一起,其他开发者很难快速判断 “这到底在检查什么?”
如何清晰分离(实战架构)
推荐分层架构(Controller -> Service/Model):
表单验证层:使用专用验证器
常用工具:Laravel FormRequest, Symfony Validator, 或自己封装的 Validator 类。
目标:在请求到达 Controller/Service 之前,确保数据“长得对”。
- :
required(必填)email(邮箱格式)alpha_num(字母数字)integer(整数)min:8,max:255(长度限制)in:status_a,status_b(枚举值)
- 验证通过后:返回清洁后的数据(例如只取允许的字段到
$dto或$cleanData)。
// 示例:Laravel FormRequest
class StoreUserRequest extends FormRequest {
public function rules() {
return [
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'password' => 'required|string|min:8|confirmed',
];
}
}
// Controller 中使用
public function register(StoreUserRequest $request) {
$validated = $request->validated(); // 此时数据格式已完全验证通过
// 传递到业务层
$this->userService->register($validated);
}
业务验证层:在 Service 中使用 Guard / Validator
目标:在执行业务操作前,检查当前上下文是否允许。
-
:
- 唯一性:
if ($this->userRepo->existsByEmail($email)) throw new UserAlreadyExistsException(); - 权限:
if (!$user->can('create_post')) throw new UnauthorizedException(); - 状态:
if ($order->status !== OrderStatus::Pending) throw new InvalidOrderStateException(); - 依赖:
if ($stock < $quantity) throw new InsufficientStockException(); - 规则组合:
if ($this->isPromoActive() && !$this->userIsVip()) throw new PromoNotAvailableException();
- 唯一性:
-
推荐模式:使用 Specification Pattern 或者 业务异常。
// 示例:Service 层的业务验证
class UserService {
public function register(array $data): User {
// 1. 业务验证 (唯一性)
$this->ensureEmailIsUnique($data['email']);
$this->ensureNameIsUnique($data['name']);
// 2. 核心业务逻辑 (创建用户)
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
// 3. 触发的后续业务 (例如发送欢迎邮件)
Event::dispatch(new UserRegistered($user));
return $user;
}
private function ensureEmailIsUnique(string $email): void {
if (User::where('email', $email)->exists()) {
throw new UserAlreadyExistsException('该邮箱已被注册');
}
}
private function ensureNameIsUnique(string $name): void {
if (User::where('name', $name)->exists()) {
throw new UserAlreadyExistsException('该用户名已被占用');
}
}
}
Controller 层:只负责协调和响应
class UserController extends Controller {
public function __construct(private UserService $userService) {}
public function register(StoreUserRequest $request) {
try {
// 表单验证已由 StoreUserRequest 自动完成
$validated = $request->validated();
// 执行业务逻辑 (包含业务验证)
$user = $this->userService->register($validated);
return response()->json($user, 201);
} catch (UserAlreadyExistsException $e) {
// 处理业务异常
return response()->json(['error' => $e->getMessage()], 409); // 409 Conflict
} catch (\Exception $e) {
// 处理其他异常
return response()->json(['error' => 'Registeration failed'], 500);
}
}
}
为什么必须分离?(底层逻辑)
- 防御式编程:第一道防线是表单验证(防垃圾数据),第二道防线是业务验证(防逻辑错误),如果表单验证混了业务逻辑,第一道防线一破,第二道防线也形同虚设。
- DDD 聚合根:在领域驱动设计中,业务验证是领域模型的责任,表单验证是应用层的职责,把它俩分开,才能让领域模型专注于核心业务规则,而不受 HTTP 输入等细节的干扰。
- 审计日志:业务验证失败(如“余额不足”)通常比表单验证失败(如“输入了非法字符”)具有更高的业务价值,需要记录更详细的审计日志,分开后更容易实现。
- 错误处理差异:
- 表单验证错误:通常返回 422 Unprocessable Entity + 字段级错误信息。
- 业务验证错误:通常返回 409 Conflict(资源冲突), 403 Forbidden(权限), 404 Not Found, 400 Bad Request(逻辑错误)。
快速检查清单
当你在开发时,问自己以下问题:
- 这个验证需要查数据库吗? → 是 => 业务验证
- 这个验证会随着促销活动 / 用户等级 / 时间变化吗? → 是 => 业务验证
- 这个验证只是检查格式 / 长度 / 是否为空吗? → 是 => 表单验证
- 这个错误是否表示“系统规则不允许”(如余额不足)? → 是 => 业务验证
- 这个错误是否表示“用户输入有误”(如日期格式错误)? → 是 => 表单验证
| 验证类型 | 检查什么 | 失败示例 | 执行层 | 错误 HTTP 状态码 |
|---|---|---|---|---|
| 表单验证 | 格式、基本完整性、必填 | “邮箱格式不对”、“密码太短” | Controller / Middleware / Request | 422 |
| 业务验证 | 逻辑、权限、状态、唯一性、资源可用性 | “用户名已存在”、“余额不足”、“库存为 0” | Service / Domain Model | 409 / 403 / 404 / 400 |
一句话诀窍: 把表单验证看作“门卫”,检查证件是否齐全;把业务验证看作“法官”,检查行为是否合法。