Eloquent 的查询作用域(Scopes)是个好东西,用好了能让代码精简不少,但用砸了,排查问题的过程能让人怀疑人生。尤其是那些“明明逻辑对,但数据就是不对”的诡异场景,十有八九是作用域在背后搞鬼。下面这几个核心痛点,是线上环境实实在在踩过的坑。
本地作用域:小心思,大问题
断链的源头,往往只是一个 return
写本地作用域最基础也最要命的一点:必须返回 $query 实例。Eloquent 在调用本地作用域时,会把当前查询构造器实例传进去,它需要你把这个“接力棒”再传回来。一旦断了,后面跟的 where、orderBy 这些方法就会报错,或者更糟糕——静默失效,查出来的数据跟预想的不一样。
- 错误示范:
public function scopeActive($query) { $query->where('status', 'active'); }—— 这里就漏了关键的return。 - 正确写法:
public function scopeActive($query) { return $query->where('status', 'active'); } - 别在作用域里“翻旧账”: 别想着去获取
$this->id之类的模型实例属性。此时模型还没从数据库里加载出来,你拿到的只会是 null 或者抛个异常。 - 命名守规矩: 方法必须是
public的,并且严格以scope开头,比如scopeLatest。调用的时候直接用->latest(),Eloquent 会自动解析。
带参数的作用域,是另一个常见的“翻车”现场
参数类型不匹配、空值没处理、或者传了个不该传的对象进去,是本地作用域最常出问题的几个地方。PHP 8+ 的严格类型检查会让这类问题直接报错,但在低版本 PHP 里,它可能只在运行时悄悄崩溃。
- 把类型写死: 比如
public function scopeWithinDays($query, int $days = 7),这样你就不可能传个字符串'30'进去,导致subDays()报错。 - 对可为空的参数做好兜底:
public function scopeByCategory($query, ?string $category = null) { return $category ? $query->where('category', $category) : $query; }这样,传了null就直接返回原始查询,不出错。 - 别把请求上下文带进来: 本地作用域不是控制器,它不应该知道
Request或Auth的存在。别把$request对象传进去,耦合度高不说,还影响测试。 - 快速验证的方法: 写完作用域,直接在 Tinker 里跑
User::where('name', 'test')->byCategory('admin')->get(),比写单元测试更快发现参数问题。
全局作用域:CLI 环境下的“幽灵”
全局作用域最大的问题在于,它在 CLI 或队列任务里拿不到 HTTP 请求上下文。如果你在 apply() 方法里硬编码从 session() 或 request() 里取租户 ID,那这些任务一跑,条件必然为空或者直接报错。
- 正确的租户 ID 获取方式: 必须通过服务容器来解析,比如
app(TenantManager::class)->currentId()。这个TenantManager类需要设计好面对 CLI 环境时的备用逻辑。 - 别用
whereNotNull这种“万能药”兜底: 在apply()里写whereNotNull('tenant_id')这种条件,会污染所有查询,甚至让forceDelete()都带上这个限制,后果很严重。 remove()方法不是摆设: 做数据迁移或者后台管理时,需要用Model::withoutGlobalScopes()->get()来跳过全局作用域。但前提是你的全局作用域确实实现了remove()方法,很多人的代码里都漏了这一步。- 别重复造轮子: Lara vel 自带的
SoftDeletestrait 已经帮你管理了删除状态的查询。如果你再手动加一层全局whereNull('deleted_at'),就会和它冲突,导致restore()等方法失效。
scopeWithoutTrashed:自定义软删除作用域的“陷阱”
很多人在作用域里直接写个 whereNull('deleted_at') 当成“不包含已删除”的条件。这很危险——万一哪天这个模型没启用 SoftDeletes trait,这个条件就变成了一个无意义的过滤,还可能掩盖掉业务流程上的错误。
- 加一层保护: 正确的做法是加个判断:
if (static::usesSoftDelete()) { $query->whereNull('deleted_at'); } - 别在
boot()里强制加条件: 官方明确反对在boot()方法里用addGlobalScope()强制加软删除条件。这会让restore()和后台列表功能出问题。 - 优先用
withoutTrashed(): 日常查询直接用Model::withoutTrashed()就好,它内部已经包含了安全判断。只有在需要封装特定语义(比如叫scopePublished())时,才考虑自建作用域。 - 注意重复条件: 如果你同时用了自己写的
scopeWithoutTrashed()和 Lara vel 自带的withoutTrashed(),SQL 里会出现两个WHERE deleted_at IS NULL。虽然逻辑没错,但查不出数据时排查起来就很费劲了。
总的来说,写对一个作用域本身不难。真正考验水平的,是确保它在命令行、队列、API、后台管理这些不同上下文中,行为表现完全一致。全局作用域常在非 HTTP 场景下静默失效,本地作用域则常因参数校验不严搞出线上异常。这些问题往往都没日志、没提示,只能靠扎实的测试覆盖和良好的调用习惯来兜底。