laravel多租户嵌套查询绕过tenant_id全局作用域是必然行为,因作用域仅作用于主查询,子查询为独立querybuilder实例;须在with()、wherehas()、whereexists()及原生子查询中显式注入tenantcontext::id()或使用tobase()复用模型作用域。

在 Laravel 多租户系统中,嵌套查询(如子查询、with() 关联预加载、whereHas()、whereExists())极易绕过 tenant_id 全局作用域,导致跨租户数据泄露——这不是偶发 Bug,而是 Eloquent 查询构造机制决定的必然行为。
嵌套查询为何会跳过全局作用域
全局作用域只作用于「主模型的根查询」,而嵌套查询是独立构建的新 QueryBuilder 实例。例如 User::with('orders') 中的 orders 子查询不继承 User 模型的 tenant_id 作用域;同样,whereHas('orders') 或 DB::table('orders')->whereRaw('...') 这类原始嵌套写法完全脱离模型上下文。
这一步必须理解:作用域不是 SQL 层面的“自动 WHERE”,而是 PHP 层对 QueryBuilder 的动态钩子。子查询不复用父查询实例,钩子自然失效。
with() 预加载强制租户过滤
方法一:闭包约束显式注入 tenant_id
在 with() 中传入闭包,手动添加 where 条件:
User::with(['orders' => function ($query) { $query->where('tenant_id', TenantContext::id()); }])->get();
方法二:为关联模型启用自身全局作用域(推荐)
确保 Order 模型已使用 BelongsToTenantTrait 并注册了全局作用域——这样即使单独调用 Order::all() 或出现在 with() 中,tenant_id 过滤也会生效。关键点:【Order 模型的全局作用域必须读取 TenantContext::id(),而非 $this->tenant_id】,否则子查询中模型尚未 hydrate,属性为空。
whereHas() 和 whereDoesntHave() 安全写法
第一步:禁止直接写 whereHas('orders') —— 它会查全表订单
第二步:改用带闭包的语法,显式限定租户上下文:
User::whereHas('orders', fn ($q) => $q->where('tenant_id', TenantContext::id()))->get();
第三步:若关联关系定义了 foreignKey 但没加 tenant_id 约束,仍可能漏过滤。务必确认 Order 模型的 belongsTo 关系未被误设为无租户感知的静态外键匹配。
注意:whereHas 的闭包内不能调用 $q->whereTenant() 这类自定义 scope,除非该 scope 内部也严格使用 TenantContext::id() —— 很多团队在这里踩坑,scope 里写了 auth()->user()->tenant_id,结果队列任务或 API 网关透传场景下直接返回空或错租户。
原生子查询(DB::table + selectSub)隔离操作
方法1:用 DB::table 构建子查询时,必须手写 tenant_id 条件
$sub = DB::table('orders')->select('user_id')->where('tenant_id', TenantContext::id())->where('status', 'paid');
User::whereIn('id', $sub)->get();
方法2:若子查询需复用模型逻辑,先获取带作用域的 QueryBuilder
$subQuery = Order::where('status', 'paid')->toBase(); // toBase() 保留全局作用域已注入的条件
User::whereIn('id', $subQuery)->get();
【toBase() 是关键,不能用 get() 或 toSql() 替代——前者执行查询,后者只生成 SQL 字符串且丢失绑定参数】
高级嵌套:whereExists 安全封装
① 直接写法(易错):
User::whereExists(function ($query) { $query->select(DB::raw(1))->from('orders')->whereColumn('orders.user_id', 'users.id'); })->get();
→ 错!子查询没 tenant_id 条件,查的是所有租户的订单
② 正确写法(双层 tenant_id 约束):
User::whereExists(function ($query) { $query->select(DB::raw(1))→from('orders')→whereColumn('orders.user_id', 'users.id')→where('orders.tenant_id', TenantContext::id()); })->get();
③ 更健壮写法(复用模型查询构造):
User::whereExists(Order::whereColumn('orders.user_id', 'users.id')->selectRaw('1')->toBase())->get();
这一步操作起来很简单,直接把 toBase() 接在带全局作用域的模型查询后面就行。











