什么是作用域
Eloquent 的作用域是将查询约束打包并可复用的机制。作用域分两类。
| 类型 | 应用时机 | 用途 |
|---|
| 全局作用域 | 始终自动应用 | 软删除、多租户、公开筛选 |
| 本地作用域 | 显式调用时才应用 | 「热门文章」「活跃用户」等通用筛选 |
本地作用域
本地作用域通过给模型方法加上 #[Scope] 属性来定义。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
/**
* 只筛选已发布的文章
*/
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published');
}
/**
* 只筛选热门文章
*/
#[Scope]
protected function popular(Builder $query): void
{
$query->where('views', '>', 1000);
}
}
#[Scope] 属性位于 Illuminate\Database\Eloquent\Attributes 命名空间,是 PHP 8.0 之后的原生语法。
已定义的作用域可以像方法一样调用,也可以链式使用。
use App\Models\Post;
// 仅获取已发布的文章
$posts = Post::published()->get();
// 获取已发布且热门的文章,按最新排序
$posts = Post::published()->popular()->latest()->get();
传递参数
可以在作用域方法的第 2 个参数之后定义额外参数。
#[Scope]
protected function ofStatus(Builder $query, string $status): void
{
$query->where('status', $status);
}
调用时直接传参即可。
$posts = Post::ofStatus('draft')->get();
$posts = Post::ofStatus('published')->get();
与 orWhere 的组合
用 orWhere 连接作用域时,有时需要逻辑分组。
// 使用闭包(可靠但冗长)
$users = User::popular()->orWhere(function (Builder $query) {
$query->active();
})->get();
// 使用高阶方法可以写得更简洁
$users = User::popular()->orWhere->active()->get();
全局作用域
全局作用域是实现了 Illuminate\Database\Eloquent\Scope 接口的类。该接口仅要求实现 apply 一个方法。
// 框架本体的接口定义
// src/Illuminate/Database/Eloquent/Scope.php
interface Scope
{
public function apply(Builder $builder, Model $model);
}
在 apply 方法中给查询构造器附加约束。
创建全局作用域类
使用 make:scope 命令生成模板。
php artisan make:scope ActiveScope
将生成 app/Models/Scopes/ActiveScope.php。
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
/**
* 将作用域应用到查询构造器
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_active', true);
}
}
应用到模型
使用 #[ScopedBy] 属性注册(推荐)
Laravel 13 中使用 #[ScopedBy] 属性最简洁。<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([ActiveScope::class])]
class User extends Model
{
//
}
可以将多个作用域以数组形式指定。#[ScopedBy([ActiveScope::class, TenantScope::class])]
class User extends Model
{
//
}
在 booted() 方法中手动注册
也可以覆写 booted 方法并调用 addGlobalScope。<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new ActiveScope);
}
}
添加全局作用域后,User::all() 等所有查询都会自动附加 WHERE is_active = 1。
匿名闭包形式的全局作用域
对于不需要单独放在文件里的简单作用域,可以用闭包定义。
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
要移除以闭包定义的作用域,必须使用作用域名(字符串),而不是类名。
移除全局作用域
在特定查询中你可能希望禁用作用域。
use App\Models\Scopes\ActiveScope;
// 移除特定作用域
User::withoutGlobalScope(ActiveScope::class)->get();
// 移除以闭包定义的作用域
User::withoutGlobalScope('active')->get();
// 移除所有全局作用域
User::withoutGlobalScopes()->get();
// 移除多个作用域
User::withoutGlobalScopes([ActiveScope::class, TenantScope::class])->get();
// 除指定作用域外全部移除
User::withoutGlobalScopesExcept([TenantScope::class])->get();
框架内部:SoftDeletingScope
看看 Laravel 标准的 SoftDeletes trait 如何使用全局作用域,就能了解实现模式。
SoftDeletingScope 实现了 Scope 接口。
// src/Illuminate/Database/Eloquent/SoftDeletingScope.php
class SoftDeletingScope implements Scope
{
protected $extensions = [
'Restore', 'RestoreOrCreate', 'CreateOrRestore',
'WithTrashed', 'WithoutTrashed', 'OnlyTrashed',
];
/**
* 将作用域应用到查询构造器
* 添加只取 deleted_at 为 NULL 的记录的约束
*/
public function apply(Builder $builder, Model $model)
{
$builder->whereNull($model->getQualifiedDeletedAtColumn());
}
/**
* 向查询构造器扩展宏
* 让 withTrashed() / onlyTrashed() 等方法可用
*/
public function extend(Builder $builder)
{
foreach ($this->extensions as $extension) {
$this->{"add{$extension}"}($builder);
}
// 重写 delete() 操作为更新 deleted_at 的处理
$builder->onDelete(function (Builder $builder) {
$column = $this->getDeletedAtColumn($builder);
return $builder->update([
$column => $builder->getModel()->freshTimestampString(),
]);
});
}
}
withTrashed() 实际上是调用 withoutGlobalScope($this)。也就是说,通过移除 SoftDeletingScope 自身,让被删除的记录也能被取到。protected function addWithTrashed(Builder $builder)
{
$builder->macro('withTrashed', function (Builder $builder, $withTrashed = true) {
if (! $withTrashed) {
return $builder->withoutTrashed();
}
return $builder->withoutGlobalScope($this);
});
}
onlyTrashed() 同样先移除自身作用域,再附加 whereNotNull('deleted_at')。protected function addOnlyTrashed(Builder $builder)
{
$builder->macro('onlyTrashed', function (Builder $builder) {
$model = $builder->getModel();
$builder->withoutGlobalScope($this)->whereNotNull(
$model->getQualifiedDeletedAtColumn()
);
return $builder;
});
}
Scope 接口未定义 extend 方法,但 Eloquent 的 Builder 会自动调用作用域上存在的 extend 方法。要添加自定义宏时可以利用它。
实战用例
多租户:按租户 ID 自动过滤
SaaS 应用中,对所有查询自动加上租户 ID 过滤非常重要。
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenantId = auth()->user()?->tenant_id) {
$builder->where('tenant_id', $tenantId);
}
}
}
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([TenantScope::class])]
class Post extends Model
{
//
}
这样只需调用 Post::all(),就能得到当前登录用户所在租户的数据。
公开/非公开筛选
管理后台希望显示未发布的文章,前台仅显示已发布文章的场景。
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', 'published')
->where('published_at', '<=', now());
}
}
在管理后台用 withoutGlobalScope 移除作用域。
// 前台:仅显示已发布(自动应用 PublishedScope)
$posts = Post::latest()->get();
// 管理后台:显示全部文章
$posts = Post::withoutGlobalScope(PublishedScope::class)->latest()->get();
请使用 addSelect 而非 select
在全局作用域中添加列时请使用 addSelect 而不是 select。使用 select 会覆盖调用方查询已选择的列。// 反例:覆盖了调用方的 select
public function apply(Builder $builder, Model $model): void
{
$builder->select('id', 'tenant_id', 'name');
}
// 正例:在已有 select 上追加
public function apply(Builder $builder, Model $model): void
{
$builder->addSelect('tenant_id');
}
下一步
Eloquent 自定义类型转换
将属性转换逻辑实现为自定义 Cast,并结合 Value Object 模式使用。