上周四晚上十一点,我盯着屏幕上一段堆栈,从 Illuminate\Validation\Validator 一路追到 app/Rules 目录下的某个自定义 Rule 对象,发现问题出在一个看起来人畜无害的 mixed 类型标注上。那个瞬间突然意识到——Laravel 表单验证里的自定义规则,其实是我们日常编码中最容易被忽略的类型漏洞入口。
一个自定义规则引发的线上故障
事情要从一个自定义验证规则说起。项目里有个 的 Rule 类,用来在用户提交表单时核验当前账号状态是否允许执行后续操作。代码大概长这样:
class CheckUserStatus implements Rule
{
public function passes($attribute, $value): bool
{
$status = User::where('id', $value)->value('status');
return in_array($status, ['active', 'pending']);
}
}
看着没什么毛病对吧?参数 $value 在 FormRequest 里传进来,按预期是个整型用户 ID。但问题在于——Laravel 自定义 Rule 的 passes 方法签名里,第二个参数类型写的是 mixed。这意味着你无法在编译期约束它到底该接什么。那天前端改了请求结构,把原本单字段的 user_id 改成了数组 user_ids,但验证规则没同步更新。$value 变成数组后,User::where('id', $value) 直接把整个数组当参数传进去了。
结果是什么?验证通过了(因为 where('id', [1, 2, 3]) 在某些 MySQL 版本下会转成字符串 'Array',查不到数据返回 null,in_array(null, ...) 返回 false,但验证器没抛异常),下游业务逻辑却因为拿不到用户记录,直接抛 。排查了大半个晚上,最后发现是 mixed 类型在作祟。
这种类型混淆在 Laravel 生态里非常隐蔽。框架本身没有强制要求自定义 Rule 的参数类型,IDE 也不会帮你检查——因为基类接口定义就是 mixed。而静态分析工具如 PHPStan 如果没针对 Laravel 的验证上下文做额外配置,同样会放过这种隐患。
PHPStan如何捕捉FormRequest中的类型隐患
有了前一章那个 mixed 带来的午夜惊魂,我们自然会想:能不能把这类问题拦在上线前?这就是 PHPStan 的舞台。
从零到一装上 Larastan
项目根目录执行 ,两行命令就能让 Laravel 生态里的规则被识别。默认配置文件 phpstan.neon 只需一行 level: max,即可开启最严格的类型检查——连可能的 null 合并都不肯放过。
# phpstan.neon
parameters:
level: max
paths:
- app/Http/Requests
- app/Rules
当 passes() 遇上类型冲突
假设你写了一条 规则,并在 FormRequest 里声明 rules() 为 ['user_id' => new CheckUserStatus]。IDE 不会报警,因为 Rule 接口定义的 passes($attribute, $value) 第二个参数是 mixed。PHPStan 则会沿着调用链回溯:如果在 passes 内部明确把 $value 当作 int 处理,而实际传进来的是 string,静态分析立刻标红。更狠的是,如果 $value 其实是数组,而你直接传给 User::where('id', $value),它会提示 "Argument 2 expects int, array given"。
- 自定义 Rule 对象接收 int,但请求体传入的是 string,PHPStan 立即警告。
- 若
$value变成数组,而 passes 内部没有做 is_int 或 is_array 判断,同样会被标记。
它把原本只有在运行时才会炸的雷,提前画成了红色波浪线。
闭包验证器中的注入风险与静态检测
闭包验证器看起来人畜无害。在 rules() 方法里直接塞一个匿名函数,三行代码搞定数据库唯一性校验——谁没写过?问题在于,这种写法天然把数据库查询逻辑和用户输入揉在了一起,而且没有任何中间层做过滤。
// app/Http/Requests/StoreUserRequest.php
public function rules(): array
{
return [
'email' => [
'required',
'email',
function ($attribute, $value, $fail) {
$user = User::where('email', $value)->first();
if ($user) {
$fail('邮箱已被占用');
}
},
],
];
}
这段代码能跑,测试能过,甚至 Code Review 也可能被放过——因为闭包里的逻辑太直白了。但你要是把 $value 直接拼接到 where 子句里呢?
function ($attribute, $value, $fail) {
$user = DB::select("select * from users where email = '$value'");
// 闭包内直接用字符串拼接,SQL注入风险
if ($user) {
$fail('邮箱已被占用');
}
}
PHPStan 在不做额外配置的情况下,对这种拼接不会直接报 SQL 注入——它不分析字符串内容。但 Larastan 的规则集里,Larastan\Rules\NoUnsafeSqlInjection 会标记 DB::select() 接收动态构造的 SQL 字符串为潜在风险。更常见的情况是,你在闭包里调用 Rule::unique()->where() 并传入了未过滤的变量。
Rule::unique()->where() 里的“隐性拼接”
Laravel 的 Rule::unique() 本意是好的——它让你用链式调用构造一条数据库唯一性约束。但很多人会在 where() 闭包里直接引用外部变量:
use Illuminate\Validation\Rule;
public function rules(): array
{
$teamId = $this->input('team_id'); // 用户传来的,未经过滤
return [
'username' => [
Rule::unique('users')->where(function ($query) use ($teamId) {
$query->where('team_id', $teamId); // 直接把用户输入给了查询构造器
}),
],
];
}
这里 $teamId 是字符串还是整数?如果请求里传了 team_id=1; DROP TABLE users,$query->where() 用的是参数绑定,Laravel 的查询构造器会做转义,所以 SQL 注入风险低。但类型混淆呢?PHPStan 会盯着 $teamId 的类型——它来自 $this->input(),返回 mixed。你把 mixed 传给 where() 的第二个参数,静态分析工具会警告:“Parameter #2 $value of method Illuminate\Database\Query\Builder::where() expects array|string, mixed given.”
这条警告容易被忽略,因为它不是“错误”,是“可能有问题”。但正是这种模糊地带,藏着线上故障的导火索。
修复:绑定参数,而不是绑定变量
修复方式其实很机械,但需要养成习惯。闭包内尽量不用外部变量拼接,改用查询构造器的参数绑定机制。或者——更彻底的做法——把闭包逻辑封装成自定义 Rule 类,让 passes() 方法只接收经过 FormRequest 验证后的数据对象。
// 改用参数绑定
$query->where('team_id', (int) $teamId); // 显示类型转换
// 或者通过验证数据对象传递
$validated = $this->validated();
$teamId = $validated['team_id']; // 此时已经是经过类型转换后的值
PHPStan 能帮你抓到的是:当你在闭包里 use ($teamId) 时,$teamId 的类型是否明确。如果它还是 mixed,静态分析立刻亮黄灯。别等到线上跑出 SQL 错误或者类型异常才回头改,那种半夜被报警吵醒的滋味,我不想再体验了。
上一章我们提到把逻辑封装进 Rule 对象更干净,但“干净”不代表“安全”。当你手写自定义规则时,一个最容易被忽视的细节是 passes() 方法的返回值类型。Laravel 底层期待它是一个明确的布尔值,可不少开发者写着写着,顺手就 return 了 null,甚至 return 了错误消息字符串。结果呢?验证器看到非 false 的值,默认认为规则通过了。这种逻辑反转的坑,PHPStan 能直接抓出来。
自定义规则返回类型错误导致验证失效
看这段典型误用。为了在验证失败时附带具体原因,有人把错误消息当返回值丢了出去。
class BelongsToTeamRule
{
public function passes($teamId, $userId): mixed
{
if (! $teamId || ! $userId) {
// ❌ 这里应该调用 fail 回调,却直接 return 了字符串
return '团队不存在';
}
return (int) $teamId === (int) $userId;
}
}
本地测试时输入空值,你会得到一条“团队不存在”的错误提示,看起来一切正常。但 PHPStan 会报警:passes 方法声明应返回 bool,实际可能返回 string。更致命的是运行时行为——因为 Laravel 只有收到 false 才会判负,字符串属于“真值”,验证居然通过了。这就意味着未授权的用户只要传个无效 ID,系统就会放行。
正确写法很简单:别自己 return 消息,改用验证器提供的 $fail 回调。
public function passes($teamId, $userId, callable $fail): void
{
if (! $this->teamExists($teamId)) {
$fail('团队不存在'); // ✅ 显式触发失败,返回类型保持 void
return;
}
if ((int) $teamId !== (int) $userId) {
$fail('用户不属于该团队');
}
}
通过给 passes 方法加上 void 返回类型声明,PHPStan 会强制要求你要么返回 bool,要么调用 $fail。想偷偷混过去?没门。
在CI流程中集成PHPStan验证检查
聊了这么多手工排查的细节,但说句实话——靠人眼盯代码是反人性的。你写了八个小时业务逻辑,脑子早就成一锅粥了,这时候指望自己还记得检查 passes 方法签名对不对?不可能的。
所以必须把这件事扔给机器。
我项目的 CI 目前在 GitHub Actions 上跑。流程不复杂:PR 提上来之后,先跑一个专门扫 FormRequest 的 Job,用 PHPStan 走 level 6 检查。
关键配置只有几行。先看看 里我们怎么抠参数的:
parameters:
level: 6
paths:
- app/Http/Requests
excludePaths:
- vendor/*
- tests/*
checkMissingIterableValueType: true
checkUninitializedProperties: true
treatPhpDocTypesAsCertain: false
reportPossiblyNonexistentGeneralArrayOffset: false
checkGenericClassInNonGenericObjectType: false
注意 设为 true。这选项很要命——Laravel 的 rules() 方法返回的是数组,数组元素混着字符串和 Rule 对象。没这个检查,PHPStan 会放过很多混搭的类型隐患。
GitHub Actions 那边,我用了一个小技巧:只扫描 PR 变动的文件。不是每次把全部请求类拉起来跑一遍——项目大了以后那耗时受不了。
name: PHPStan FormRequest Check
on:
pull_request:
paths:
- 'app/Http/Requests/**'
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Run PHPStan
run: vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=256M
这里有个坑:paths 过滤只限制了触发条件,但 PHPStan 跑的时候还是会拉全部文件。所以别忘了在 里干掉 vendor 和 tests,不然跑一次两分钟起步。
我们团队定了个死规矩:新增的自定义规则类,必须能通过 level 6。没过就直接在 PR 上画红叉,不让合并。最初有人嫌烦——「我就加一个简单的 exists 验证,你让我写 return type?」后来确实在 code review 里抓到过两次因为忘了调用 $fail 回调而导致的验证静默通过,大家就闭嘴了。
阈值这个东西,设低了没感觉,设高了整天被 false positive 搞得想骂人。Level 6 是个不错的折中点——它抓 nullable 冒泡、void 返回声明缺失,但不会抱怨你用了动态方法调用(那是 level 8 的活,暂时还没必要摊这浑水)。
整个流程跑下来,最爽的不是「多抓了几个 bug」,而是 code review 的时候再也不用扯那种「你这里是不是少了个 return」的废话了。机器帮你说了,大家聊业务逻辑就好。
从一次重构看静态分析带来的长期收益
把 FormRequest 里的自定义验证规则梳理一遍之后,很多「看着没问题」的代码暴露出真面目:字符串和整型互转、隐式依赖容器、闭包里忘记调用 fail 回调。那次重构我们没动业务逻辑,先把 PHPStan 调到 level 6,跑出来的问题直接修,结果一次性清掉 12 处类型混淆和 3 处潜在注入。
// 之前这样写很顺手:
'email' => [Rule::unique('users'), 'required'],
// PHPStan 会提醒:unique 返回的是 Rule|string,别随手当 bool 用。
后来我们把规则拆成独立的 Rule 对象,构造器里直接把 repository 传进去——不靠容器那套隐式解析,也不怕它偷偷返回个错误类型。上线跑了两个月,跟验证有关的 422 和空指针大概降了八成,CI 上的红叉肉眼可见地少了,review 的时候也不用再对着那几行挠头问“这里会不会翻车”。
工具这东西吧,抓多少 bug 其实没那么重要——关键是让团队别再为了那些边角细节扯皮,能把精力省下来放业务上。把 level 6 塞进合并门槛以后,PR 里的口水战基本就绝迹了。





评论