以前做表单提交,总是看到控制器里满屏的 if ($_POST[‘name’] == ”),或者一堆验证规则堆在一起。这次用 ThinkPHP8 的验证器把逻辑理清楚,真的能少写一百行废话。最关键是场景验证这个功能,同一个模型在登录和注册时用不同规则,不用再造两个类。
从一个真实的坑说起
我接手过一个老项目,里面有个“用户注册”的接口,控制器里密密麻麻写了十几次 if 判断。用户名长度、密码复杂度、邮箱格式,还有手机号规则。后来要加一个“修改个人资料”的接口,发现很多规则重叠,但又有细微差别。当时懒得重构,就复制粘贴改了一通,结果手机号规则居然漏掉了,导致用户能提交乱七八糟的号码。
所以后面我直接在 ThinkPHP 里用验证器 + 场景,把每个字段的验证规则定义好,不同场景设置不同的“启用/禁用”状态。代码清爽得不像话,关键是出错了知道去哪改。
验证器基本结构
在 ThinkPHP8 中,我们通常会在 app/validate/ 目录下新建一个验证器类。比如 User.php,里面主要定义 protected $rule 和 protected $message。废话不多说,直接上代码。
<?php
namespace appvalidate;
use thinkValidate;
class User extends Validate
{
protected $rule = [
'username' => 'require|min:3|max:20',
'password' => 'require|min:6|max:30',
'email' => 'email',
'phone' => 'mobile',
'gender' => 'in:0,1,2',
];
protected $message = [
'username.require' => '用户名必填',
'username.min' => '用户名最少3个字符',
'username.max' => '用户名最多20个字符',
'password.require' => '密码必填',
'password.min' => '密码最少6位',
'password.max' => '密码最多30位',
'email.email' => '邮箱格式不对',
'phone.mobile' => '手机号码不合法',
'gender.in' => '性别取值异常',
];
// 场景设置
protected $scene = [
'register' => ['username', 'password', 'email', 'phone'],
'edit' => ['username', 'email', 'gender'],
'login' => ['username', 'password'],
'changePwd' => ['password'],
];
}
看到没?$scene 数组定义了不同场景下需要验证哪些字段。比如 edit 场景里没有 phone,所以编辑个人资料时就不用担心手机号了。而且我并没有为每个场景单独建类,这就防止了代码重复。
为什么场景这么好用?
比如注册场景,你要验证 username、password、email、phone。而改基本信息时,不应该强制改密码,也不该改手机号,所以场景里只放 username、email、gender。如果你以后再做一个接口“仅修改密码”,那直接定义一个 'changePwd' => ['password'],里面只检查密码规则即可。
而且你可以给某个场景指定某个字段的额外规则,写法是:
protected $scene = [
'register' => ['username'=>'require|min:5', 'password', 'email', 'phone'],
];
这样在注册场景中,username 的规则就不是类里面定义的 min:3,而是 min:5,即覆盖了基本规则。注意如果没有覆盖则自动使用全局 $rule 中对 username 的规则。
控制器里怎么用?
在控制器中,你只需要用门面验证器或者依赖注入,然后调用 scene() 方法选择场景,最后 check() 一下输入数据。下面是 ThinkPHP8 控制器的典型写法:
<?php
namespace appcontroller;
use thinkfacadeValidate;
use appvalidateUser as UserValidate;
class Auth
{
// 注册
public function register()
{
$data = [
'username' => input('username'),
'password' => input('password'),
'email' => input('email'),
'phone' => input('phone'),
];
$validate = new UserValidate();
if (!$validate->scene('register')->check($data)) {
return json(['code' => 1, 'msg' => $validate->getError()]);
}
// 验证通过,写入数据库...
return json(['code' => 0, 'msg' => '注册成功']);
}
// 编辑资料
public function edit()
{
$data = [
'username' => input('username'),
'email' => input('email'),
'gender' => input('gender'),
];
$validate = new UserValidate();
if (!$validate->scene('edit')->check($data)) {
return json(['code' => 1, 'msg' => $validate->getError()]);
}
// 更新资料...
return json(['code' => 0, 'msg' => '已保存']);
}
// 登录
public function login()
{
$data = [
'username' => input('username'),
'password' => input('password'),
];
$validate = new UserValidate();
if (!$validate->scene('login')->check($data)) {
return json(['code' => 1, 'msg' => $validate->getError()]);
}
// 登录逻辑...
}
}
你看,控制器根本不需要写各种 if 嵌套,验证器帮你搞定一切。而且因为验证器是一个独立类,以后想改规则,直接动 UserValidate,不用翻控制器代码。
自定义规则:有些验证不能用内置的
内置规则虽然多,但总有意想不到的坑。比如“验证密码是否包含字母和数字”,这得自己写。ThinkPHP8 支持两种方式:一种是给验证器类增加一个方法,比如 checkPassword,另一种是使用闭包。这里我推荐用前者,更好复用和测试。
拿“密码复杂度”为例,在验证器类里加一个方法:
// 验证密码要包含字母和数字
protected function checkPasswd($value, $rule, $data)
{
if (!preg_match('/[A-Za-z]/', $value)) {
return '密码必须包含字母';
}
if (!preg_match('/d/', $value)) {
return '密码必须包含数字';
}
return true;
}
然后在规则里引用这个方法名:
'password' => 'require|min:6|max:30|checkPasswd',
注意:自定义方法名在验证器类中必须以 check 开头,后面的名字随意,这里就是 checkPasswd。然后验证器会自动转换成 checkPasswd 调用,传递三个参数:$value(当前字段值)、$rule(规则参数,如果规则字符串中有冒号后面的内容)、$data(整个数据集)。
如果你只想在这个规则里加个参数,比如密码长度至少 10 位,那可以写成:
'password' => 'require|checkPasswd:10',
然后在方法里接住参数:
protected function checkPasswd($value, $rule)
{
$minLen = $rule ?: 8;
if (strlen($value) < $minLen) {
return '密码长度至少' . $minLen . '位';
}
return true;
}
很灵活有木有。
场景 + 自定义规则解决一个常见复杂案例
假设用户注册时,手机号是可选的,但如果你填写了就必须是合法的中国手机号;如果没填,则不允许给空字符串。这个规则用场景怎么设计?
我们先在验证器里为手机号单独加一个“非必填,但要合法”的规则:
protected function checkMobileOptional($value)
{
if ($value === '' || $value === null) {
return true; // 允许为空
}
if (!preg_match('/^1[3-9]d{9}$/', $value)) {
return '手机号格式不正确';
}
return true;
}
然后全局规则中把 phone 改成这样:
'phone' => 'checkMobileOptional',
在注册场景中,你仍然把 phone 列入,这样如果用户填入了一个手机号,就会自动验证;如果为空,也可以跳过。这就是“可选但校验”的爽点。
如果你希望在某些场景(比如管理员后台)手机号必填,那可以这样定义另一个场景:
protected $scene = [
'admin_add' => ['username', 'password', 'phone'=>'require|mobile'],
];
这里对 phone 字段使用内置的 require 和 mobile,覆盖了全局的可选规则。完美。
验证器 vs 表单请求
Laravel 有 FormRequest,ThinkPHP8 虽然没有完全等价物,但验证器类已经够用。你可以把验证器类理解为“独立校验层”,你可以在控制器中使用门面或者注入。如果你想做中间件,甚至可以在路由阶段就验证,但我觉得放在控制器里最直观。
什么时候用验证器而不是自己写逻辑?
只要你的字段超过两个,就建议上验证器。即使简单到只要非空检查,用验证器也会让你的控制器看起来更清爽。特别是团队协作的时候,后来接手的人一看验证器就知道字段规则,不用满项目找 if。而且 ThinkPHP8 验证器还支持跳出自定义错误消息,前端可以精准提示用户。
实际项目里一个坑是:当验证不通过时,getError() 返回的可能是一个数组,而不是字符串。你可以把验证器的 $dispatcher 设置为空或者用批量验证?这个问题可以这样解决,在控制器里做类型判断:
$error = $validate->getError();
if (is_array($error)) {
$error = implode(',', $error);
}
其实多数情况它是字符串。但如果你开启了批量验证,就会变成数组啦。为了避免前端看到奇怪的东西,建议统一处理一下。
另一个进阶玩法:验证器里用闭包
有些规则只在某一种业务场景中出现,且不好抽象成方法。你可以在规则里直接写闭包,比如:
'username' => function($value) {
// 不能包含敏感词
if (strpos($value, 'admin') !== false) {
return '用户名不允许包含admin';
}
return true;
},
但这有个问题:一旦这个规则定义后,所有场景都会生效,除非你在场景里手动覆盖取消。我认为闭包适合临时用,长期维护的话还是建议用命名方法。
配合表单令牌更完美
验证器还能检查表单令牌,防止 CSRF。你在验证器里加一个字段 '__token__' => 'token',然后表单中输出 <input type="hidden" name="__token__" value="{$Request.token}" />,控制器里正常用验证器 check 即可。这比每个控制器手动检查要省事。
很多人不知道 TP8 内置了 token 验证规则,其实有,用在验证器里就是 token。不过平时写 API 接口都用 JWT,用不到,但 Web 页面表单建议开启。
总结一下
说白了,ThinkPHP8 的验证器与场景是一个很成熟的“表单请求校验”解决方案。你只需要把字段规则集中在验证器类里,通过 scene() 控制不同场景的字段组合,再用自定义方法去覆盖那些特定业务规则。控制器和业务代码就会变得非常干净,不容易遗漏校验,也更方便后期维护。
下次写接口的时候,别再一长串 if (empty($name)) 了,试着用验证器把规则收拢起来。可能一开始你觉得多了一个类有点麻烦,但用上两个项目以后,你一定会真香。

