不少 Laravel 入门教程为了演示方便,会直接在 routes/web.php 的闭包里写数据库查询,比如 Route::get('/users', function () { return User::all(); });。这种写法在小 demo 里没问题,可一旦搬到正式项目,安全隐患和维护成本就会集中爆发。这篇文章就来聊聊路由层直接查库到底有哪些坑,以及对应的规范做法。

一、路由闭包直接查库存在哪些隐患
首先是分层混乱的问题。Laravel 的设计哲学是路由只负责“把请求分发到对应的处理逻辑”,具体业务应该交给控制器、服务层和模型。如果直接在闭包里写查询,等于把路由文件变成了业务逻辑堆放地。项目规模一大,routes 目录会膨胀成几千行的“巨型文件”,你想找到某个接口的逻辑得在路由文件里翻半天,而且无法使用路由缓存。
其次是注入风险被放大。闭包里的代码往往缺少参数校验环节,开发者容易随手拼接查询条件。比如下面的写法:
// 危险示例:直接拼接用户输入
Route::get('/search', function (\Illuminate\Http\Request $request) {
$keyword = $request->input('keyword');
// 用 whereRaw 拼接字符串,存在注入风险
$users = DB::table('users')
->whereRaw("name = '" . $keyword . "'")
->get();
return $users;
});
虽然 Laravel 的查询构造器和 Eloquent 默认使用 PDO 参数绑定,本身能防住绝大多数 SQL 注入,但 whereRaw、orderByRaw 这类原生方法一旦配合字符串拼接,防线就形同虚设。闭包路由缺少了 FormRequest 这一层校验,非法输入更容易直接穿透到 SQL 层。
还有一个容易被忽视的问题:闭包路由无法享受 php artisan route:cache 的加速。路由缓存要求所有路由都使用控制器,闭包路由会导致缓存命令直接报错,高并发场景下性能损失明显。此外,闭包里写查询还容易触发 N+1 问题,因为没有统一的资源层去做关联预加载。
二、安全查询的正确姿势:校验、绑定与预加载
如果项目暂时还比较小,确实想在路由层快速实现查询,至少要做到三件事:输入校验、参数绑定、限制返回字段。先看一个改进后的示例:
use App\Models\User;
use Illuminate\Http\Request;
Route::get('/users', function (Request $request) {
// 1. 校验输入,白名单约束
$data = $request->validate([
'keyword' => 'nullable|string|max:50',
'page' => 'nullable|integer|min:1',
]);
// 2. 使用查询构造器,值通过参数绑定传入
$users = User::query()
->select('id', 'name', 'email')
->when($data['keyword'] ?? null, function ($query, $keyword) {
$query->where('name', 'like', '%' . $keyword . '%');
})
->orderBy('id')
->paginate(15);
return response()->json($users);
});
这里的 where('name', 'like', $value) 会自动走 PDO 参数绑定,$keyword 中的单引号、注释符都会被当作普通字符处理,注入风险基本消除。同时 select 限定字段,避免把 password_hash 这类敏感字段一股脑返回给前端。
涉及关联数据时,一定要用预加载替代循环查询。很多人会写出 User::all() 然后在视图里循环访问 $user->posts,这样每一条用户记录都会触发一次额外 SQL。正确的做法:
// 预加载关联,一条主查询 + 一条关联查询
$users = User::with('posts:id,user_id,title')
->select('id', 'name')
->get();
预加载不仅把查询次数从 1+N 降到固定两条,还应该像上面那样给关联指定字段,减少不必要的数据传输。如果统计类的需求,用 withCount 比 with 更轻量,它只返回计数字段而不加载整个关联模型。
三、迁移到控制器与服务层的最佳实践
真正的规范做法是让路由保持“轻薄”,只做一行声明。把查询逻辑迁移到控制器,必要时再下沉到 Service 层,配合 FormRequest 完成校验。推荐的目标结构如下:
// routes/web.php 路由只留声明
Route::get('/users', [UserController::class, 'index']);
// app/Http/Controllers/UserController.php
class UserController extends Controller
{
public function index(UserIndexRequest $request)
{
// 查询逻辑收敛到 Service,控制器只做调度
$users = app(UserService::class)->search(
$request->validated()
);
return UserResource::collection($users);
}
}
// app/Http/Requests/UserIndexRequest.php
class UserIndexRequest extends FormRequest
{
public function rules(): array
{
return [
'keyword' => ['nullable', 'string', 'max:50'],
'page' => ['nullable', 'integer', 'min:1'],
];
}
}
这种分层带来几个直接收益:FormRequest 把校验规则独立成类,可以复用也便于测试;控制器变成纯调度层,代码量骤减;API 资源类(Resource)统一了响应结构,敏感字段的过滤集中在 toArray 里做,不会遗漏。测试时你可以直接实例化 Service 类做单元测试,不必每次都模拟完整的 HTTP 请求。
最后再补充两个工程层面的细节。第一,启用路由缓存提升性能:php artisan route:cache,前提是所有路由都指向控制器方法,这也是倒逼你清理闭包路由的好办法。第二,数据库层面做好兜底,生产环境使用只读账号连接查询接口,配合 MySQL 的权限限制,即使代码出现疏漏也能把影响控制到最小。对于后台管理类的敏感查询,加上 middleware('auth') 和策略类(Policy)做双重授权,避免越权查询的发生。
总结一下:路由闭包直接查库不是语法错误,而是架构上的坏味道。临时验证可以用,正式项目请把校验交给 FormRequest、把查询交给 Service、把响应交给 Resource,让每一层只做自己的事,安全性和可维护性都会同步提升。