把一套跑了七八年的遗留 PHP 系统往 Laravel 迁移,你最怕什么?不是路由改写,也不是 ORM 换脸——是那些散落在几百个文件里的 mysql_query("SELECT * FROM users WHERE id = " . $_GET['id'])。根本没人敢动,因为你不知道哪条查询会在线上突然炸开。我翻代码审查了三天,眼都花了,结果上线第一周还是被 SQL 注入打了个措手不及。
那次之后我就明白了,靠人眼去盯几百个文件里的字符串拼接,纯属自虐。你看到第 50 个 mysql_query 的时候还能保持警惕,看到第 350 个的时候,脑子已经自动把那行当成安全样板了。
更坑的是,很多遗留代码里的 SQL 拼接不是那种明显危险。比如 sprintf("SELECT * FROM products WHERE category_id = %d", $catId),看起来用了格式化,但 $catId 可能来自 $_COOKIE,而 PHP 的 %d 并不会真的把字符串转成整数。还有那些藏在循环里的 SELECT * FROM orders WHERE user_id = $uid——没加索引的单表扫,每页请求能跑出来 200 条独立查询。手动审查根本没法量化这种 N+1 问题,你翻代码时觉得“就一条 SQL”,等上了生产发现数据库 CPU 拉到 90% 才反应过来。
静态分析怎么把“主观阅读”变成可重复执行的规则
我接手过一个用 CodeIgniter 2 写的电商后台,光 select 字符串拼接就有四百多处。团队说“我们每行都看过了”,但最后还是漏了一条 order by 参数直接拼进去的查询——攻击者可以从排序字段注入。手动审查的问题就在这里:人眼对重复模式的疲劳是生理性的,你没办法指望一个人在看完 300 行代码后还能保持同样的判断力。
PHPStan 在这里的价值不是替代人的判断,而是把审查变成可重复执行的规则。比如你写一条自定义规则 :任何调用了 mysql_query 且第二个参数不是 mysqli 预处理语句的文件,直接报 error。再比如检测 DB::select("SELECT * FROM $table") 中 $table 变量没有被 allow 白名单约束——这种模式在迁移过渡期特别常见,因为大家急着把旧查询包进 Laravel 的 DB 门面,却忘了加参数绑定。
我实际跟过一个案例:一套 2015 年的 Yii 1 系统,迁移前用 PHPStan 跑了 8 级扫描,自定义规则只写了三条——、、。结果扫出来的问题比手动审查多出两倍,其中有一条是 WHERE id IN (implode(',', $ids))——$ids 没做整形过滤,攻击者可以传 1,2,3); DROP TABLE users; --。手动审查时所有人都觉得 IN 语句很安全,因为“字段是整数”。静态分析不靠直觉,它只看你的约束条件:你声明了 $ids 是 int[] 吗?没有?那就报错。
动手写一条抓 SQL 注入的自定义规则
上一章聊完静态分析怎么把“肉眼审查”压成可重复执行的规则,这章直接上真家伙。我写了一条 PHPStan 自定义规则 ,专门逮遗留系统里最常见也最要命的 SQL 注入写法——听起来玄乎,其实就两步:解析 PHP 的 AST,定位特定函数调用,然后看一眼参数是不是被直接塞进了不该进的地方。
先把危险函数圈出来。Legacy 系统最常见的雷区就那几个:mysql_query、、pg_exec、。它们的第一个参数必须是字符串,第二个参数往往就是用户可控的数据。我们在规则里不讨论业务逻辑,只看调用形式——只要发现 mysql_query('SELECT ...' , $v) 且 $v 属于超全局或未经显式净化的变量,就直接报错。
PHPStan 的规则本质是 PhpParser 的 NodeVisitor。写个继承自 的类 ,在 enterNode 里判断 FuncCall 节点:如果 name->toString() 等于 'mysql_query' 且 args[1] 是 Variable,就把该变量名记下来。随后检查它有没有被 filter_var($v, FILTER_SANITIZE_NUMBER_INT) 或 intval($v) 覆盖;如果没有,那这就是一个高危点。别忘了,有些项目会把 DB 操作包一层 getUserById,这时你得继续追踪返回值是否进了 mysql_query 的第二个参数,否则规则会漏报。
<?php
declare(strict_types=1);
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
class NoRawSqlQuery extends NodeVisitorAbstract
{
public function enterNode(Node $node)
{
if (!$node instanceof Node\Expr\FuncCall) {
return null;
}
if (!$node->name instanceof Node\Name || $node->name->toString() !== 'mysql_query') {
return null;
}
$secondArg = $node->args[1]??null;
if (!$secondArg instanceof Node\Expr\Variable) {
return null;
}
// 进一步检查变量是否来自 $_GET / $_POST / $_COOKIE …
return ['error' => 'Detected potentially unsafe SQL parameter', 'line' => $node->getLine()];
}
}
写完 visitor 后把它注册到规则扩展里,就能在 PHPStan 扫描时触发 error。在 的 rules 段引用命名空间即可。建议先设 level 3,以免一次性报太多历史债让人想关报警。别忘了给新项目加 baseline,让旧问题安静地躺在那里,新提交却必须通过规则。
执行 vendor/bin/phpstan analyse src --level=3,你会看到类似 "Detected potentially unsafe SQL parameter" 的提示。优先改那些真的会被外部输入触达的点:把 mysql_query 换成 mysqli_stmt + bind_param,或者迁移到 Laravel 的 DB::select('...', [$binding])。对于暂时无法重构的老代码,可以临时加 filter_var 兜底,并在规则里标记 @no-sql-injection-custom 注解来豁免。全部修完,把 level 升到最高,确保后续合并不会把老坑又带回来。
自动抓 N+1 和索引缺失,别让性能债拖后腿
上一章我们把 mysql_query 裸调用抓得差不多了,但真正让遗留系统变慢的往往不是注入风险——是查询写得实在太糙。循环里套查询、WHERE 子句对着没索引的字段猛怼,这种事在老旧代码里比比皆是。PHPStan 能不能管这种“性能债”?能,而且管起来比想象中直接。
N+1 问题在遗留系统里长什么样?你打开一个订单列表,foreach 遍历订单,然后在循环体里又执行 SELECT * FROM order_items WHERE order_id = ?。每多一个订单就多发一次查询。10 个订单就是 11 次查询,100 个就是 101 次。这种模式在 PHP 5 时代的代码里几乎是标配,因为那时没人跟你提 Eager Loading。
写一条 PHPStan 规则抓这个其实不难。关键是要在 AST 里匹配到 foreach 语句内部出现了 或 DB::select 的调用。我写了个 visitor ,遍历 Stmt\Foreach_ 节点,然后递归检查其子节点里有没有 Expr\FuncCall 或 Expr\MethodCall 并且目标函数是数据库查询方法。
class NPlusOneDetector implements PHPStan\Rules\Rule
{
public function getNodeType(): string
{
return Stmt\Foreach_::class;
}
public function processNode(Node $node, Scope $scope): array
{
$queryCalls = [];
$this->findQueryCalls($node->stmts, $queryCalls);
if (!empty($queryCalls)) {
return [
new RuleError('Potential N+1 query inside foreach: ' . implode(', ', $queryCalls))
];
}
return [];
}
private function findQueryCalls(array $stmts, array &$calls): void
{
foreach ($stmts as $stmt) {
if ($stmt instanceof Node\Expr\FuncCall &&
$stmt->name instanceof Node\Name &&
$stmt->name->toString() === 'mysqli_query') {
$calls[] = 'mysqli_query at line ' . $stmt->getLine();
}
// 递归检查子语句
if (property_exists($stmt, 'stmts')) {
$this->findQueryCalls($stmt->stmts, $calls);
}
}
}
}
跑一次分析,你会看到类似 “Potential N+1 query inside foreach: mysqli_query at line 142” 的提示。这时候手动改成 Order::with('items') 或者先查出所有订单 ID 再批量查明细就行。对于那种实在改不动的大循环,可以在规则里加白名单:如果 foreach 内部已经调用了 array_map 或批量查询方法,就跳过。
另一个常见问题是 WHERE 条件里的字段根本没索引。比如 SELECT * FROM users WHERE status = 'active',而 status 字段连个普通索引都没有。表一上百万行,每次查询都是全表扫描。这种问题靠人工 review 很难覆盖全,但 PHPStan 可以帮你扫一遍所有 SQL 字面量。
思路是:抓到字符串参数里包含 SELECT 模式的,提取出字段名,然后跟预定义的“已知索引字段列表”比对。这个列表可以从 的结果里导出成 PHP 数组,塞到规则配置里。
class MissingIndexRule implements PHPStan\Rules\Rule
{
private array $indexedColumns;
public function __construct(array $indexedColumns)
{
$this->indexedColumns = $indexedColumns;
}
public function getNodeType(): string
{
return Node\Expr\FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Name || $node->name->toString() !== 'mysqli_query') {
return [];
}
$firstArg = $node->args[0] ?? null;
if (!$firstArg || !$firstArg->value instanceof Node\Scalar\String_) {
return [];
}
$sql = $firstArg->value->value;
if (preg_match('/WHERE\s+(\w+)\s*=/i', $sql, $matches)) {
$column = $matches[1];
if (!in_array($column, $this->indexedColumns, true)) {
return [
new RuleError("Query uses WHERE on '$column' which has no index")
];
}
}
return [];
}
}
在 里传递索引列表:
rules:
- App\Phpstan\MissingIndexRule
parameters:
indexedColumns:
- id
- user_id
- created_at
- status
这个规则有个局限:它只能分析硬编码的 SQL 字面量。如果 SQL 是通过拼接变量生成的,那就抓不到。但对遗留系统来说,那些写死的查询字符串恰好是最容易改的突破口。先把这部分清干净,动态拼接的部分留到下一轮。
光报错还不够,开发者在修复时往往不知道“该怎么改才对”。PHPStan 的规则可以返回带建议的错误消息。比如检测到 N+1 时,直接告诉对方“把 foreach 里的查询替换为 Model::with('relation')”。我写了个辅助方法 ,根据 SQL 中的表名和 WHERE 字段推断出可能需要的关联关系。比如看到 WHERE order_id = ?,就推断是 OrderItem 模型上有个 order 关联,建议改成 OrderItem::with('order')。准确率大概七成,够用。不准确的时候,开发者自己判断一下就行,总比从头查文档强。
private function suggestEagerLoading(string $table, string $column): string
{
$map = [
'order_items' => ['order_id' => 'order'],
'comments' => ['post_id' => 'post'],
'logs' => ['user_id' => 'user'],
];
$model = Str::studly(Str::singular($table));
$relation = $map[$table][$column] ?? $column;
return "Consider using {$model}::with('{$relation}')";
}
把这条建议拼到错误消息里,开发者看到提示就能直接抄过去用。对于索引缺失的问题也一样:报错时附带 ALTER TABLE table ADD INDEX (column) 的 DDL 语句,省得再去翻数据库工具。
修完一轮后你会发现,最爽的不是速度提升了多少倍——是 CI 终于能在代码合并前就告诉你“你这段 foreach 会炸数据库”,而不是等线上告警才去查慢查询日志。那种被动救火的感觉,经历过的人都不想再来一次。
把规则塞进迁移工作流,让它活下来
前面把规则写完、本地也能跑通之后,真正的考验才来:怎么让它在团队里活下去。最容易死掉的就是“只在我机器上能跑”,所以得把它塞进 CI/CD,跟合并请求绑死。
我在 .github/workflows/phpstan.yml 里加了一步:PR 触发、只扫改动行,新增目录走 level=max,老代码暂时 level=7(兼容遗留)。关键两句是 composer phpstan analyse src/ —level=7 和对新模块用 —level=max,再把错误当成失败 exit 1。这样谁都不敢随手带一个 sql 拼接提交进来。
<?php // composer.json
"scripts": {
"analyse:new": "phpstan analyse src/NewModule --level=max --error-format=github",
"analyse:legacy": "phpstan analyse src/Legacy --level=7 --error-format=github"
}
错误格式我用 github,方便直接贴到 PR 注释里;顺便把生成的 ALTER TABLE 语句也附上,审阅者点开就能看到建议。
一口气把所有历史债务都改完不现实,于是我们在 里按模块拆分 ruleSet。新模块强禁止 mysql_*、禁止 PDO 裸拼、强制参数化查询,外加最小结果集限制;老模块先放过函数名风格,只抓高危注入点和明显的 N+1。每两周把一个老模块的 level 往上提一档,像分期付款一样慢慢收紧。
- 新模块:level=max + 自定义
+ - 老模块:level=7 + 仅启用
与
这套组合的好处是,开发者不会因为一次提交被打得满头包,但也没有借口说“以后再说”。
迁移脚本本身也是代码,同样会被扫描。我在 里为数据库层单独写了夹层: 只检查 schema 定义是否带上了索引与外键约束, 负责看 之后的 update/select 是否用了 DB::raw。若有违规,git commit 之前就会先报警。
最后加了一条小钩子:每次 git commit 生成文件后,先跑 analyse:new,通过才允许 commit。刚开始有人抱怨卡流程,后来发现很多坑还没踩就被拦住了,大家反而愿意主动跑一遍。
两轮迭代后的效果
两轮迭代跑下来,我们拿旧代码仓库的 baseline 做了个对比。迁移前三周,我在遗留模块里手动扫出来 47 个明显 SQL 注入点——大多是 mysql_query("SELECT * FROM users WHERE id = " . $_GET['id']) 这种连 addslashes 都懒得写的。两个月后,自定义规则跑完最后一轮扫描,新仓库里高危注入标记归零。不是“接近于零”,是 0。
查询性能的提升倒不是靠某个单一规则。N+1 检测那一条就抓出了 30 多处循环里调查询的写法,改完以后最夸张的一个报表页面从 9 秒掉到 0.4 秒——那页面之前每次加载都要跑 200 多次独立的 SELECT。 还帮我们补了 5 个本该有复合索引但只建了单列的表,执行计划里 type 从 ALL 变成了 ref。
原本还怕团队觉得这是“机器骑到人头上”。结果上线第一周,就有同事在群里甩了个👑,说这波梭哈真值了。他的原话:以前审代码得把每条 SQL 拷进 EXPLAIN 里一条条看,现在 CI 直接怼脸上——LINE 34: 缺少 WHERE 条件,LINE 67: 建议加复合索引 (status, created_at)。审查就从“拍脑袋”变成了“点确认”。两个月后拉了笔数据:合并到 master 的 PR 里因为数据库问题被打回去的,从每周 4~5 次降到了不到 1 次。说实话,这数字我自己都有点意外。
当然也出过笑话。有个老兄在迁移时把 DB::raw("COUNT(*) as cnt") 写成了 DB::raw("COUNT(*) as cunt"),我们的命名规范规则直接把它标红,改过来之后他哭笑不得地说“静态分析救了我在 code review 上的脸面”。
这套东西跑顺之后,最大的感受倒不是效率翻了多少倍——心态变了。以前动遗留系统的数据库层,改个 WHERE 条件都怕把全表锁死,加个字段总担心关联查询的索引没跟上。现在大部分坑 CI 直接就帮你踩了,你只用盯着规则提示之外的那些边界情况。迁移这事本身其实还好,真正值钱的是团队慢慢养成了一个习惯:写查询之前先琢磨一下“静态分析会怎么说”。从手动补漏到机器把关,这条路走得不算亏。当然规则覆盖不全,跨库 JOIN 还是得人肉审——不过半夜被线上慢查询叫醒的日子,确实少了太多。
评论