Lzh on GitHub

引言

数据库中的数据表之间通常存在关联。例如,一篇博客文章可能拥有多条评论,或者某个订单可能关联到下单的用户。Eloquent 让管理和处理这些关系变得非常轻松,并支持多种常见的关系类型:

  • 一对一(One To One)
  • 一对多(One To Many)
  • 多对多(Many To Many)
  • 通过关系(Has One Through)
  • 多个通过关系(Has Many Through)
  • 一对一(多态,Polymorphic)
  • 一对多(多态,Polymorphic)
  • 多对多(多态,Polymorphic)

定义关联关系

Eloquent 关系是以方法的形式定义在你的 Eloquent 模型类中的。由于关系本身也是强大的查询构造器,将关系定义为方法能够带来强大的链式调用和查询能力。 例如,我们可以在 posts 关系上继续追加查询条件:

$user->posts()->where('active', 1)->get();

但在深入使用关系之前,我们先来了解如何定义 Eloquent 支持的各种关系类型。

一对一

一对一关系是一种最基础的数据库关系类型。例如,一个 User 模型可能关联一个 Phone 模型。为了定义这种关系,我们需要在 User 模型上定义一个 phone 方法。phone 方法需要调用 hasOne 方法并返回其结果。hasOne 方法来自模型继承的 Illuminate\Database\Eloquent\Model 基类:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;

class User extends Model
{
    /**
     * 获取与该用户关联的手机。
     */
    public function phone(): HasOne
    {
        return $this->hasOne(Phone::class);
    }
}

hasOne 方法的参数含义

传递给 hasOne 的第一个参数是关联模型的类名。关系定义好之后,我们就能通过 Eloquent 的 动态属性 来访问关联的数据。动态属性允许我们像访问属性一样访问关系方法:

$phone = User::find(1)->phone;

外键的默认约定

Eloquent 会根据 父模型名称 自动推断外键。在这个例子中,它会默认认为 Phone 模型中存在 user_id 字段作为外键。如果你想覆盖这一约定,可以给 hasOne 传入第二个参数:

return $this->hasOne(Phone::class, 'foreign_key');

此外,Eloquent 默认外键值对应父模型的主键(通常是 id)。如果你希望使用父模型的其他字段来作为本地键,可以传入第三个参数:

return $this->hasOne(Phone::class, 'foreign_key', 'local_key');

定义关系的反向关联

我们已经能从 User 访问到它的 Phone,现在我们在 Phone 模型上定义一个反向关系,让它能访问拥有它的用户。

使用 belongsTo 方法即可定义一对一关系的反向:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Phone extends Model
{
    /**
     * 获取拥有该手机的用户。
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

当调用 user 方法时,Eloquent 会查找 idPhone 模型的 user_id 字段匹配的 User 记录。

Eloquent 会通过检查关系方法的名称,并在方法名后加上 _id 来推断外键名称。因此,在本例中,Eloquent 会认为 Phone 模型中存在一个 user_id 字段。

不过,如果 Phone 模型中的外键并不是 user_id,你可以在 belongsTo 方法中通过第二个参数来自定义外键名称。

public function user(): BelongsTo
{
    return $this->belongsTo(User::class, 'foreign_key');
}

如果父模型并未使用 id 作为主键,或者你希望通过父表中的其他字段来查找关联模型,你可以在 belongsTo 方法中传入第三个参数,用来指定父表的自定义主键字段。

public function user(): BelongsTo
{
    return $this->belongsTo(User::class, 'foreign_key', 'owner_key');
}

一对多

一对多关系用于定义一个模型作为父级,拥有一个或多个子模型的关系。例如,一篇博客文章可以拥有无限数量的评论。和所有其他 Eloquent 关系一样,一对多关系通过在模型中定义一个方法来实现:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Post extends Model
{
    /**
     * 获取该博客文章的所有评论。
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

请记住,Eloquent 会自动推断 Comment 模型的外键列。按照约定,Eloquent 会取父模型名称的蛇形命名(snake case),并追加 _id。因此在这个例子中,Eloquent 会认为 Comment 模型的外键列是 post_id

定义关系方法之后,我们就可以通过访问 comments 属性来获取关联的评论集合。请注意,由于 Eloquent 提供了 “动态关系属性”,我们可以像访问属性一样访问关系方法:

use App\Models\Post;

$comments = Post::find(1)->comments;

foreach ($comments as $comment) {
    // ...
}

由于所有关系同时也是查询构建器,你可以通过调用 comments 方法,并继续链式添加条件来为关系查询增加约束:

$comment = Post::find(1)->comments()
    ->where('title', 'foo')
    ->first();

hasOne 方法类似,你也可以通过向 hasMany 方法传递额外的参数来覆盖外键和本地键:

return $this->hasMany(Comment::class, 'foreign_key');

return $this->hasMany(Comment::class, 'foreign_key', 'local_key');

自动为子模型填充父模型

即使在使用 Eloquent 预加载(eager loading)时,如果你在遍历子模型时访问父模型,也可能会出现 “N + 1” 查询问题:

$posts = Post::with('comments')->get();

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->post->title;
    }
}

在上面的示例中,即使我们已经为每个 Post 模型预加载了 comments,Eloquent 也不会自动将父级 Post 实例注入到每个 Comment 实例中,因此仍然会产生 “N + 1” 查询问题。

如果你希望 Eloquent 自动为子模型注入父模型,可以在定义 hasMany 关系时调用 chaperone 方法:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Post extends Model
{
    /**
     * 获取该博客文章的所有评论。
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class)->chaperone();
    }
}

或者,如果你希望在运行时按需开启自动父模型注入,也可以在预加载关系时使用 chaperone

use App\Models\Post;

$posts = Post::with([
    'comments' => fn ($comments) => $comments->chaperone(),
])->get();

一对多(反向)

现在我们已经可以访问某篇文章的所有评论了,接下来让我们为评论定义一个关系,使其能够访问所属的父级文章。要定义 hasMany 关系的反向关系,只需在子模型上定义一个调用 belongsTo 的关系方法:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Comment extends Model
{
    /**
     * 获取拥有该评论的文章。
     */
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}

定义关系后,我们即可通过 “动态关系属性” 访问某条评论所属的文章:

use App\Models\Comment;

$comment = Comment::find(1);

return $comment->post->title;

在上面的例子中,Eloquent 会尝试查找一条 Post 记录,其 id 字段与 Comment 模型的 post_id 字段匹配。

Eloquent 会根据关系方法名自动推断默认外键名称:取方法名并加上 _ 与父模型主键名。因此在这里,Eloquent 会认为 comments 表的外键是 post_id

如果你的外键不符合这种约定,你可以将自定义外键作为第二个参数传递给 belongsTo 方法:

/**
 * 获取拥有该评论的文章。
 */
public function post(): BelongsTo
{
    return $this->belongsTo(Post::class, 'foreign_key');
}

如果你的父模型不使用 id 作为主键,或者你希望通过其它字段建立关联,可以将第三个参数传递给 belongsTo 方法,用于指定父表的自定义键:

/**
 * 获取拥有该评论的文章。
 */
public function post(): BelongsTo
{
    return $this->belongsTo(Post::class, 'foreign_key', 'owner_key');
}

默认模型(Default Models)

belongsTohasOnehasOneThroughmorphOne 关系允许你定义一个“默认模型”,当关系为 null 时返回该默认模型。这种模式通常称为“空对象模式”(Null Object Pattern),能够减少代码中的条件判断。如下例所示,如果 Post 模型没有关联用户,user 关系将返回一个空的 App\Models\User 模型实例:

/**
 * 获取文章的作者。
 */
public function user(): BelongsTo
{
    return $this->belongsTo(User::class)->withDefault();
}

要为默认模型填充属性,你可以向 withDefault 方法传入一个数组或闭包:

/**
 * 获取文章的作者。
 */
public function user(): BelongsTo
{
    return $this->belongsTo(User::class)->withDefault([
        'name' => 'Guest Author',
    ]);
}
/**
 * 获取文章的作者。
 */
public function user(): BelongsTo
{
    return $this->belongsTo(User::class)->withDefault(function (User $user, Post $post) {
        $user->name = 'Guest Author';
    });
}

查询 BelongsTo 关系

查询某个“belongs to”关系的子模型时,你可以手动构建 where 查询:

use App\Models\Post;

$posts = Post::where('user_id', $user->id)->get();

但你可能会更愿意使用 Laravel 提供的 whereBelongsTo 方法,它会自动推断合适的关系及外键:

$posts = Post::whereBelongsTo($user)->get();

你也可以传入一个集合,此时 Laravel 将获取属于该集合中任意父模型的所有记录:

$users = User::where('vip', true)->get();

$posts = Post::whereBelongsTo($users)->get();

默认情况下,Laravel 会根据提供的模型类名推断要使用的关系;但你也可以通过传入第二个参数明确指定关系名称:

$posts = Post::whereBelongsTo($user, 'author')->get();

多个中的一个

有时,一个模型可能关联了许多其他模型,但你希望能够轻松获取该关系中 “最新” 或 “最早” 的模型。例如,User 模型可能关联了多个 Order 模型,但你想要一种便捷的方法来获取用户最近一次下的订单。你可以将 hasOne 关系类型与 ofMany 方法结合使用来实现这一点:

/**
 * 获取用户最近的订单。
 */
public function latestOrder(): HasOne
{
    return $this->hasOne(Order::class)->latestOfMany();
}

同样,你可以定义一个方法来获取关系中“最早”或第一个相关模型:

/**
 * 获取用户最早的订单。
 */
public function oldestOrder(): HasOne
{
    return $this->hasOne(Order::class)->oldestOfMany();
}

默认情况下,latestOfManyoldestOfMany 方法会根据模型的主键(必须是可排序的)获取最新或最早的相关模型。然而,有时你可能希望基于不同的排序标准,从较大的关系中获取单个模型。

例如,通过 ofMany 方法,你可以根据订单价格来获取用户“最贵的订单”。ofMany 方法的第一个参数是用来排序的列名,第二个参数为在查询相关模型时要应用的聚合函数(minmax):

/**
 * 获取用户金额最大的订单。
 */
public function largestOrder(): HasOne
{
    return $this->hasOne(Order::class)->ofMany('price', 'max');
}

由于 PostgreSQL 不支持对 UUID 列执行 MAX 函数,因此目前无法将“一对多中的一个”关系用于 PostgreSQL 的 UUID 列。

将“多”关系转换为 Has One 关系

通常,当你使用 latestOfManyoldestOfManyofMany 来获取单个模型时,你已经为相同的模型定义了一个 “has many” 关系。为方便起见,Laravel 允许你通过调用关系上的 one 方法,将其轻松转换为 “has one” 关系:

/**
 * 获取用户的所有订单。
 */
public function orders(): HasMany
{
    return $this->hasMany(Order::class);
}

/**
 * 获取用户金额最大的订单。
 */
public function largestOrder(): HasOne
{
    return $this->orders()->one()->ofMany('price', 'max');
}

你也可以使用 one 方法将 HasManyThrough 关系转换为 HasOneThrough 关系:

public function latestDeployment(): HasOneThrough
{
    return $this->deployments()->one()->latestOfMany();
}

高级 “Has One of Many” 关系

在某些场景中,你可能需要构建更复杂的 “has one of many” 关系。

例如,一个 Product 模型可能关联多个 Price 模型,这些价格记录在系统中会被保留,即便发布了新的价格。此外,新价格可能会提前发布,以便在未来某个时间生效,这个生效时间存储在 published_at 列中。

总结一下,我们需要获取产品最新的已发布价格,但前提是该发布时间不能是未来的时间。如果两个价格具有相同的发布时间,我们会优先选择 ID 最大的那条记录。

要实现这一点,我们需要向 ofMany 方法传入一个数组,该数组包含用于确定“最新价格”的可排序列。同时,ofMany 的第二个参数需要提供一个闭包,用于为关系查询添加额外约束,例如发布时间不能晚于当前时间:

/**
 * 获取产品当前的有效价格。
 */
public function currentPricing(): HasOne
{
    return $this->hasOne(Price::class)->ofMany([
        'published_at' => 'max',
        'id' => 'max',
    ], function (Builder $query) {
        $query->where('published_at', '<', now());
    });
}

通过中间模型的一对一

Has-One-Through” 关系用于定义与另一个模型的一对一关系。但与普通的一对一关系不同,这种关系表示声明模型可以通过第三个中间模型,与目标模型关联。

例如,在一个车辆维修店应用中,每个 Mechanic 模型可能关联一辆 Car,而每辆 Car 可能关联一个 Owner。虽然 Mechanic 与 Owner 在数据库中没有直接关系,但 Mechanic 可以通过 Car 访问 Owner。下面是定义该关系所需的表结构示例:

mechanics
    id - integer
    name - string

cars
    id - integer
    model - string
    mechanic_id - integer

owners
    id - integer
    name - string
    car_id - integer

在了解了该关系所需的表结构之后,我们来在 Mechanic 模型上定义这个关系:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;

class Mechanic extends Model
{
    /**
     * 获取车辆的车主。
     */
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(Owner::class, Car::class);
    }
}
  • hasOneThrough 的第一个参数是最终希望访问的模型名称(Owner)。
  • 第二个参数是中间模型名称(Car)。

如果相关模型已经定义了彼此的关系,你可以通过 through 方法以流畅语法定义 “has-one-through” 关系。例如,Mechanic 模型有 cars 关系,Car 模型有 owner 关系,则可以这样定义 Mechanic 与 Owner 的 “has-one-through” 关系:

// 基于字符串的语法
return $this->through('cars')->has('owner');

// 基于动态方法的语法
return $this->throughCars()->hasOwner();

关键约定

执行关系查询时,会遵循典型的 Eloquent 外键约定。如果需要自定义外键,可以向 hasOneThrough 方法传入第三和第四个参数:

  • 第三个参数:中间模型的外键名称
  • 第四个参数:最终模型的外键名称
  • 第五个参数:本地模型的主键
  • 第六个参数:中间模型的主键

示例:

class Mechanic extends Model
{
    /**
     * 获取车辆的车主。
     */
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(
            Owner::class,
            Car::class,
            'mechanic_id', // cars 表中的外键
            'car_id',      // owners 表中的外键
            'id',          // mechanics 表的本地键
            'id'           // cars 表的本地键
        );
    }
}

或者,如前所述,如果所有相关模型上的关系已经定义好,你可以通过调用 through 方法并提供这些关系的名称,流畅地定义一个 “has-one-through” 关系。这种方式的好处是可以复用现有关系中已经定义的键约定。

// 基于字符串的语法
return $this->through('cars')->has('owner');

// 基于动态方法的语法
return $this->throughCars()->hasOwner();

通过中间模型的一对多

“has-many-through” 关系提供了一种通过中间关系访问远程关联的便捷方式。例如,假设我们正在构建类似 Laravel Cloud 的部署平台。一个 Application 模型可能通过中间的 Environment 模型访问多个 Deployment 模型。使用这个例子,你可以轻松获取某个应用程序的所有部署。下面是定义该关系所需的表结构:

applications
    id - 整数
    name - 字符串

environments
    id - 整数
    application_id - 整数
    name - 字符串

deployments
    id - 整数
    environment_id - 整数
    commit_hash - 字符串

在了解了该关系所需的表结构后,让我们在 Application 模型上定义这个关系:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

class Application extends Model
{
    /**
     * 获取该应用的所有部署。
     */
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(Deployment::class, Environment::class);
    }
}

传递给 hasManyThrough 方法的第一个参数是我们希望访问的最终模型的名称,而第二个参数是中间模型的名称。

或者,如果相关模型之间的关系已经定义好,你可以通过调用 through 方法并提供这些关系的名称来流畅地定义一个 “多对多穿透(has-many-through)” 关系。例如,如果 Application 模型有一个 environments 关系,而 Environment 模型有一个 deployments 关系,你可以这样定义一个连接应用程序和部署的 “多对多穿透” 关系:

// 基于字符串的语法...
return $this->through('environments')->has('deployments');

// 动态语法...
return $this->throughEnvironments()->hasDeployments();

虽然 Deployment 模型的表中并不包含 application_id 列,但通过 hasManyThrough 关系,你仍然可以通过 $application->deployments 访问某个应用程序的所有部署。要获取这些模型,Eloquent 会先检查中间模型 Environment 表中的 application_id 列,然后根据找到的相关环境 ID 去查询 Deployment 模型的表。

关键约定

在执行该关系的查询时,将遵循 Eloquent 的典型外键约定。如果你希望自定义关系的键,可以将它们作为 hasManyThrough 方法的第三和第四个参数传入。第三个参数是中间模型上的外键名称,第四个参数是最终模型上的外键名称。第五个参数是本地键,而第六个参数是中间模型的本地键。

class Application extends Model
{
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(
            Deployment::class,
            Environment::class,
            'application_id', // environments 表的外键
            'environment_id', // deployments 表的外键
            'id',             // applications 表的本地键
            'id'              // environments 表的本地键
        );
    }
}

或者,如前所述,如果所有相关模型上的关系已经定义好,你可以通过调用 through 方法并提供这些关系的名称,流畅地定义一个 “has-many-through” 关系。这种方式的好处是可以复用现有关系中已经定义的键约定:

// 基于字符串的语法...
return $this->through('environments')->has('deployments');

// 动态语法...
return $this->throughEnvironments()->hasDeployments();

作用域关联

在模型中为关系添加额外的方法来约束关系是很常见的。例如,你可能会在 User 模型中添加一个 featuredPosts 方法,用于在更广泛的 posts 关系上添加额外的 where 条件来筛选特定帖子:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Model
{
    /**
     * 获取用户的所有帖子。
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class)->latest();
    }

    /**
     * 获取用户的精选帖子。
     */
    public function featuredPosts(): HasMany
    {
        return $this->posts()->where('featured', true);
    }
}

然而,如果你尝试通过 featuredPosts 方法创建模型,其 featured 属性不会自动被设置为 true。如果你希望通过关系方法创建模型,同时指定一些属性应用于通过该关系创建的所有模型,可以在构建关系查询时使用 withAttributes 方法:

/**
 * 获取用户的精选帖子。
 */
public function featuredPosts(): HasMany
{
    return $this->posts()->withAttributes(['featured' => true]);
}

withAttributes 方法会使用给定的属性向查询中添加 where 条件,同时也会将这些属性添加到通过该关系方法创建的任何模型中:

$post = $user->featuredPosts()->create(['title' => 'Featured Post']);

$post->featured; // true

如果你希望 withAttributes 方法仅设置属性而不向查询添加 where 条件,可以将 asConditions 参数设置为 false

return $this->posts()->withAttributes(['featured' => true], asConditions: false);

多对多关联

多对多关系比 hasOnehasMany 关系稍微复杂一些。一个多对多关系的例子是用户与角色的关系:一个用户可能拥有多个角色,而这些角色也可以分配给应用中的其他用户。例如,一个用户可能同时被分配 “作者” 和 “编辑” 角色;同样,这些角色也可能被分配给其他用户。所以,一个用户有多个角色,一个角色也属于多个用户。

数据表结构

要定义这种关系,需要三张数据库表:usersrolesrole_user。其中,role_user 表是由相关模型名称按字母顺序生成的中间表,包含 user_idrole_id 列。这个表用于连接用户和角色。

由于一个角色可以属于多个用户,我们不能简单地在 roles 表中添加 user_id 列,这会导致一个角色只能属于一个用户。为了支持角色可以分配给多个用户,需要 role_user 中间表。关系的表结构可以总结如下:

users
    id - integer
    name - string
roles
    id - integer
    name - string
role_user
    user_id - integer
    role_id - integer

模型结构

多对多关系通过在模型中定义一个返回 belongsToMany 方法结果的方法来建立。belongsToMany 方法由所有 Eloquent 模型继承自 Illuminate\Database\Eloquent\Model 基类提供。例如,我们可以在 User 模型中定义一个 roles 方法,第一个参数为关联模型的类名:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class User extends Model
{
    /**
     * 属于该用户的角色。
     */
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }
}

定义好关系后,可以通过 roles 动态关系属性访问用户的角色:

use App\Models\User;

$user = User::find(1);

foreach ($user->roles as $role) {
    // ...
}

由于所有关系也充当查询构建器,你可以通过调用 roles 方法并继续链式调用条件来添加额外的约束:

$roles = User::find(1)->roles()->orderBy('name')->get();

Eloquent 会根据相关模型名称的字母顺序拼接来确定中间表的表名,但你也可以自定义此约定,只需在 belongsToMany 方法中传入第二个参数:

return $this->belongsToMany(Role::class, 'role_user');

除了自定义中间表的名称,你还可以通过额外参数自定义表中键的列名。第三个参数是当前模型的外键名,第四个参数是关联模型的外键名:

return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');

定义关系的反向

要定义多对多关系的 “反向”,需要在关联模型中定义一个同样返回 belongsToMany 方法结果的方法。继续我们的用户/角色示例,在 Role 模型中定义 users 方法:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Role extends Model
{
    /**
     * 属于该角色的用户。
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

可以看到,这个关系与 User 模型中定义的关系几乎完全相同,只是引用了 App\Models\User 模型。由于我们复用了 belongsToMany 方法,在定义多对多关系的反向时,所有常用的表名和键自定义选项同样适用。

获取中间表字段

正如你已经学到的,多对多关系需要一个中间表。Eloquent 提供了一些非常便利的方式来操作这个中间表。例如,假设我们的 User 模型关联了多个 Role 模型。在访问该关系后,我们可以通过模型的 pivot 属性访问中间表:

use App\Models\User;

$user = User::find(1);

foreach ($user->roles as $role) {
    echo $role->pivot->created_at;
}

注意,每个被检索到的 Role 模型都会自动拥有一个 pivot 属性。该属性是一个模型,表示中间表的数据。

默认情况下,pivot 模型上只会包含关联模型的键。如果中间表包含额外的属性,你需要在定义关系时指定这些属性:

return $this->belongsToMany(Role::class)->withPivot('active', 'created_by');

如果你希望中间表拥有由 Eloquent 自动维护的 created_atupdated_at 时间戳,可以在定义关系时调用 withTimestamps 方法:

return $this->belongsToMany(Role::class)->withTimestamps();

使用 Eloquent 自动维护时间戳的中间表必须同时包含 created_atupdated_at 两列。

自定义 pivot 属性名称

如前所述,中间表的属性可以通过模型的 pivot 属性访问。但是,你也可以自定义这个属性名称,以更好地反映它在应用中的用途。

例如,如果你的应用中用户可以订阅播客,可能存在用户与播客之间的多对多关系。在这种情况下,你可能希望将中间表属性从 pivot 重命名为 subscription,可以在定义关系时使用 as 方法:

return $this->belongsToMany(Podcast::class)
    ->as('subscription')
    ->withTimestamps();

指定自定义中间表属性名称后,就可以使用自定义名称访问中间表数据:

$users = User::with('podcasts')->get();

foreach ($users->flatMap->podcasts as $podcast) {
    echo $podcast->subscription->created_at;
}

通过中间表字段过滤查询

你还可以在定义多对多关系时,通过 wherePivotwherePivotInwherePivotNotInwherePivotBetweenwherePivotNotBetweenwherePivotNullwherePivotNotNull 方法,对 belongsToMany 查询返回的结果进行过滤:

return $this->belongsToMany(Role::class)
    ->wherePivot('approved', 1);

return $this->belongsToMany(Role::class)
    ->wherePivotIn('priority', [1, 2]);

return $this->belongsToMany(Role::class)
    ->wherePivotNotIn('priority', [1, 2]);

return $this->belongsToMany(Podcast::class)
    ->as('subscriptions')
    ->wherePivotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']);

return $this->belongsToMany(Podcast::class)
    ->as('subscriptions')
    ->wherePivotNotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']);

return $this->belongsToMany(Podcast::class)
    ->as('subscriptions')
    ->wherePivotNull('expired_at');

return $this->belongsToMany(Podcast::class)
    ->as('subscriptions')
    ->wherePivotNotNull('expired_at');

wherePivot 会在查询中添加 where 条件,但不会在通过该关系创建新模型时自动设置指定的值。如果你希望在查询和创建关系时同时使用特定的 pivot 值,可以使用 withPivotValue 方法:

return $this->belongsToMany(Role::class)
    ->withPivotValue('approved', 1);

通过中间表字段排序查询

你可以使用 orderByPivot 方法对 belongsToMany 关系查询返回的结果进行排序。在下面的示例中,我们将获取用户的所有最新徽章:

return $this->belongsToMany(Badge::class)
    ->where('rank', 'gold')
    ->orderByPivot('created_at', 'desc');

自定义中间表模型

如果你希望为多对多关系的中间表定义自定义模型,可以在定义关系时调用 using 方法。自定义 pivot 模型允许你在 pivot 模型上定义额外的行为,例如方法和类型转换。

自定义多对多 pivot 模型应继承 Illuminate\Database\Eloquent\Relations\Pivot 类,而自定义多态多对多 pivot 模型应继承 Illuminate\Database\Eloquent\Relations\MorphPivot 类。例如,我们可以定义一个 Role 模型,并使用自定义的 RoleUser pivot 模型:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Role extends Model
{
    /**
     * 属于该角色的用户。
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)->using(RoleUser::class);
    }
}

在定义 RoleUser 模型时,应继承 Illuminate\Database\Eloquent\Relations\Pivot 类:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class RoleUser extends Pivot
{
    // ...
}

需要注意的是,pivot 模型不能使用 SoftDeletes 特性。如果需要对 pivot 记录进行软删除,可以考虑将 pivot 模型转换为真正的 Eloquent 模型。

自定义 Pivot 模型与自增 ID

如果你为多对多关系定义了自定义 pivot 模型,且该 pivot 模型有自增主键,需要确保自定义 pivot 模型类中定义了 incrementing 属性并设置为 true

/**
 * 指示 ID 是否自增。
 *
 * @var bool
 */
public $incrementing = true;

多态关联

多态关系允许子模型通过单一关联归属于多种类型的模型。例如,假设你正在构建一个应用,用户可以分享博客文章和视频。在这种情况下,Comment 模型可能同时属于 Post 模型和 Video 模型。

一对一

表结构

一对一多态关系类似于典型的一对一关系,但子模型可以通过单一关联归属于多种类型的模型。例如,一个博客 Post 和一个 User 可以共享对 Image 模型的多态关联。使用一对一多态关系,可以在系统中维护一张唯一的图片表,并将其关联到文章或用户。首先,我们来看表结构:

posts
    id - 整数
    name - 字符串
users
    id - 整数
    name - 字符串
images
    id - 整数
    url - 字符串
    imageable_id - 整数
    imageable_type - 字符串

注意 images 表中的 imageable_idimageable_type 列。imageable_id 存储所属 postuser 的 ID 值,而 imageable_type 存储父模型的类名。Eloquent 会使用 imageable_type 来判断在访问 imageable 关联时应返回哪种类型的父模型。在本例中,该列可能包含 App\Models\PostApp\Models\User

模型结构

接下来是构建该关系所需的模型定义:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Image extends Model
{
    /**
     * 获取父级 imageable 模型(User 或 Post)。
     */
    public function imageable(): MorphTo
    {
        return $this->morphTo();
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;

class Post extends Model
{
    /**
     * 获取文章的图片。
     */
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;

class User extends Model
{
    /**
     * 获取用户的图片。
     */
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

获取关系

定义好数据库表和模型后,可以通过模型访问关联。例如,要获取文章的图片:

use App\Models\Post;

$post = Post::find(1);

$image = $post->image;

要获取多态模型的父模型,可以通过调用 morphTo 的方法名来访问。在本例中,访问 Image 模型的 imageable 方法即可:

use App\Models\Image;

$image = Image::find(1);

$imageable = $image->imageable;

Image 模型上的 imageable 关联将返回 PostUser 实例,具体取决于图片所属的模型类型。

关键约定

如有必要,可以指定多态子模型使用的 “id” 和 “type” 列名称。请确保将关联名称作为第一个参数传递给 morphTo 方法。通常该值应与方法名一致,因此可以使用 PHP 的 __FUNCTION__ 常量:

/**
 * 获取图片所属的模型。
 */
public function imageable(): MorphTo
{
    return $this->morphTo(__FUNCTION__, 'imageable_type', 'imageable_id');
}

一对多

表结构

一对多多态关系类似于典型的一对多关系,但子模型可以通过单一关联归属于多种类型的模型。例如,假设你的应用用户可以对文章(posts)和视频(videos)发表评论(comments)。通过多态关系,可以使用一张 comments 表存储文章和视频的评论。首先,我们来看构建此关系所需的表结构:

posts
    id - 整数
    title - 字符串
    body - 文本
videos
    id - 整数
    title - 字符串
    url - 字符串
comments
    id - 整数
    body - 文本
    commentable_id - 整数
    commentable_type - 字符串

模型结构

接下来是构建该关系所需的模型定义:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    /**
     * 获取父级 commentable 模型(Post 或 Video)。
     */
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    /**
     * 获取文章的所有评论。
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Video extends Model
{
    /**
     * 获取视频的所有评论。
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

获取关系

定义好数据库表和模型后,可以通过模型的动态关系属性访问关联。例如,要获取文章的所有评论:

use App\Models\Post;

$post = Post::find(1);

foreach ($post->comments as $comment) {
    // ...
}

要获取多态子模型的父模型,可以通过调用 morphTo 的方法名访问。在本例中,访问 Comment 模型的 commentable 方法即可:

use App\Models\Comment;

$comment = Comment::find(1);

$commentable = $comment->commentable;

Comment 模型上的 commentable 关联将返回 PostVideo 实例,具体取决于评论所属的父模型类型。

在子模型上自动加载父模型

即使使用 Eloquent 的预加载(eager loading),如果在循环子模型时访问父模型,也可能出现 “N + 1” 查询问题:

$posts = Post::with('comments')->get();

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->commentable->title;
    }
}

上例中引入了 “N + 1” 查询问题,因为虽然对每个 Post 模型预加载了评论,但 Eloquent 并不会自动为每个 Comment 模型加载其父 Post

如果希望 Eloquent 自动将父模型注入子模型,可以在定义 morphMany 关系时调用 chaperone 方法:

class Post extends Model
{
    /**
     * 获取文章的所有评论。
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable')->chaperone();
    }
}

或者,如果希望在运行时选择性启用自动父模型注入,可以在预加载关系时调用 chaperone 方法:

use App\Models\Post;

$posts = Post::with([
    'comments' => fn ($comments) => $comments->chaperone(),
])->get();

多个中的一个

有时候,一个模型可能关联了多个模型,但你希望能够方便地获取该关系中“最新”或“最旧”的关联模型。例如,一个 User 模型可能关联了多个 Image 模型,但你希望定义一种便捷方式来获取用户上传的最新图片。你可以使用 morphOne 关系类型结合 ofMany 方法来实现:

/**
 * 获取用户最新上传的图片。
 */
public function latestImage(): MorphOne
{
    return $this->morphOne(Image::class, 'imageable')->latestOfMany();
}

同样地,你也可以定义一个方法来获取关系中“最旧”或第一个关联模型:

/**
 * 获取用户最早上传的图片。
 */
public function oldestImage(): MorphOne
{
    return $this->morphOne(Image::class, 'imageable')->oldestOfMany();
}

默认情况下,latestOfManyoldestOfMany 方法会根据模型的主键(必须可排序)来获取最新或最旧的关联模型。但有时,你可能希望根据不同的排序条件,从大量关联模型中获取单个模型。

例如,使用 ofMany 方法,你可以获取用户最“受欢迎”的图片。ofMany 方法的第一个参数是用于排序的列名,第二个参数是查询关联模型时要应用的聚合函数(minmax):

/**
 * 获取用户最受欢迎的图片。
 */
public function bestImage(): MorphOne
{
    return $this->morphOne(Image::class, 'imageable')->ofMany('likes', 'max');
}
你也可以构建更高级的 “一对多中的一” 关系。更多信息,请参考 has one of many 的官方文档。

多对多

表结构

多对多多态关系相比 “morph one” 和 “morph many” 关系稍微复杂一些。例如,Post 模型和 Video 模型可以共享一个指向 Tag 模型的多态关系。在这种情况下使用多对多多态关系,可以让你的应用只维护一个唯一标签表,并将标签关联到文章或视频。首先,我们来看构建该关系所需的表结构:

posts
    id - integer
    name - string
videos
    id - integer
    name - string
tags
    id - integer
    name - string
taggables
    tag_id - integer
    taggable_id - integer
    taggable_type - string

在深入多对多多态关系之前,建议先了解常规的多对多关系文档。

模型结构

接下来,我们在模型上定义关系。PostVideo 模型都将包含一个 tags 方法,该方法调用 Eloquent 基类提供的 morphToMany 方法。

morphToMany 方法接收相关模型名称和“关系名称”。根据我们为中间表命名及其包含的键,这里我们将关系命名为 taggable

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Post extends Model
{
    /**
     * 获取文章的所有标签。
     */
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

定义关系的反向

Tag 模型中,需要为其可能的父模型定义方法。在本例中,我们定义 postsvideos 方法。这两个方法都返回 morphedByMany 方法的结果。

morphedByMany 方法接收相关模型名称和“关系名称”。根据我们为中间表命名及其包含的键,这里也使用 taggable 作为关系名称:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Tag extends Model
{
    /**
     * 获取所有被分配此标签的文章。
     */
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    /**
     * 获取所有被分配此标签的视频。
     */
    public function videos(): MorphToMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

访问关系

当数据库表和模型定义完成后,你可以通过模型访问关系。例如,要访问某篇文章的所有标签,可以使用 tags 动态关系属性:

use App\Models\Post;

$post = Post::find(1);

foreach ($post->tags as $tag) {
    // ...
}

你也可以从多态子模型访问其父模型,方法是调用 morphedByMany 的方法名称。在本例中,即 Tag 模型上的 postsvideos 方法:

use App\Models\Tag;

$tag = Tag::find(1);

foreach ($tag->posts as $post) {
    // ...
}

foreach ($tag->videos as $video) {
    // ...
}

自定义多态类型

默认情况下,Laravel 会使用模型的完整命名空间类名来存储关联模型的“类型”。例如,在前面的一对多关系示例中,Comment 模型可能属于 PostVideo 模型,那么默认的 commentable_type 分别为 App\Models\PostApp\Models\Video。然而,你可能希望将这些值与应用的内部结构解耦。

例如,不使用模型类名作为“类型”,而是使用简单字符串如 postvideo。这样,即使模型重命名,数据库中多态类型列的值仍然有效:

use Illuminate\Database\Eloquent\Relations\Relation;

Relation::enforceMorphMap([
    'post' => 'App\Models\Post',
    'video' => 'App\Models\Video',
]);

你可以在 App\Providers\AppServiceProviderboot 方法中调用 enforceMorphMap,或者如果需要,也可以创建一个单独的服务提供者来调用。

你可以在运行时使用模型的 getMorphClass 方法获取给定模型的 morph 别名。相反地,也可以使用 Relation::getMorphedModel 方法根据 morph 别名获取完整类名:

use Illuminate\Database\Eloquent\Relations\Relation;

$alias = $post->getMorphClass();

$class = Relation::getMorphedModel($alias);
如果在现有应用中添加了 “morph map”,那么数据库中仍然使用完整类名的所有可多态 *_type 列值,都需要转换为映射表中的别名。

动态关联

你可以使用 resolveRelationUsing 方法在运行时为 Eloquent 模型定义关联关系。虽然在普通应用开发中通常不推荐这样做,但在开发 Laravel 扩展包时,这种方法有时会非常有用。

resolveRelationUsing 方法的第一个参数是你希望定义的关联关系名称。第二个参数是一个闭包函数,该函数接收模型实例并返回一个有效的 Eloquent 关联关系定义。通常,你应该在服务提供者的 boot 方法中配置动态关联关系:

use App\Models\Order;
use App\Models\Customer;

Order::resolveRelationUsing('customer', function (Order $orderModel) {
    return $orderModel->belongsTo(Customer::class, 'customer_id');
});
在定义动态关联关系时,务必为 Eloquent 关联方法提供明确的键名参数。

查询关联

由于所有 Eloquent 关联关系都是通过方法定义的,你可以调用这些方法来获取关联关系实例,而无需实际执行查询来加载关联模型。此外,所有类型的 Eloquent 关联关系本身也可作为查询构建器使用,这允许你在最终向数据库执行 SQL 查询之前继续在关联查询上链式添加约束条件。

例如,假设我们有一个博客应用,其中 User 模型与多个 Post 模型存在一对多关联:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Model
{
    /**
     * 获取用户的所有帖子。
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

你可以对 posts 关联关系进行查询,并添加额外的约束条件,例如:

use App\Models\User;

$user = User::find(1);

$user->posts()->where('active', 1)->get();

你可以在关联关系上使用 Laravel 查询构建器的任意方法,因此建议查阅查询构建器文档,了解可用的所有方法。

在关联关系后链式添加 orWhere 条件

如上例所示,你可以在查询关联关系时自由添加额外条件。然而,在关联关系上链式添加 orWhere 条件时需要小心,因为 orWhere 条件会与关联条件处于同一逻辑层级:

$user->posts()
    ->where('active', 1)
    ->orWhere('votes', '>=', 100)
    ->get();

上述示例会生成如下 SQL 查询:

select *
from posts
where user_id = ? and active = 1 or votes >= 100

可以看到,or 子句会指示查询返回任何投票数大于 100 的帖子,查询不再受限于特定用户。

在大多数情况下,你应使用逻辑分组,将条件放入括号中:

use Illuminate\Database\Eloquent\Builder;

$user->posts()
    ->where(function (Builder $query) {
        return $query->where('active', 1)
            ->orWhere('votes', '>=', 100);
    })
    ->get();

上述示例生成的 SQL 查询如下,可以看到逻辑分组正确地将条件约束在括号内,查询仍然受限于特定用户:

select *
from posts
where user_id = ? and (active = 1 or votes >= 100)

关联方法 vs 动态属性

如果你不需要在 Eloquent 关联查询中添加额外条件,也可以像访问属性一样访问关联关系。例如,延续之前的 UserPost 示例模型,我们可以这样获取某个用户的所有帖子:

use App\Models\User;

$user = User::find(1);

foreach ($user->posts as $post) {
    // ...
}

动态关联属性会执行 “延迟加载”(lazy loading),意味着只有在实际访问时才会加载关联数据。因此,开发者通常会使用预加载(eager loading)来提前加载已知会被访问的关联关系。预加载可以显著减少执行的 SQL 查询数量,从而提高加载模型及其关联关系的效率。

查询关联是否存在

在检索模型记录时,你可能希望根据关联关系的存在来限制结果。例如,假设你想获取至少有一条评论的所有博客文章,可以将关联关系的名称传递给 hasorHas 方法:

use App\Models\Post;

// 获取至少有一条评论的所有文章...
$posts = Post::has('comments')->get();

你还可以指定操作符和计数值来进一步自定义查询:

// 获取评论数大于等于三条的文章...
$posts = Post::has('comments', '>=', 3)->get();

可以使用“点”语法构建嵌套的 has 语句。例如,获取至少有一条评论且该评论至少有一张图片的文章:

// 获取至少有一条评论且该评论有图片的文章...
$posts = Post::has('comments.images')->get();

如果需要更强大的功能,可以使用 whereHasorWhereHas 方法对 has 查询添加额外的约束条件,例如检查评论内容:

use Illuminate\Database\Eloquent\Builder;

// 获取至少有一条评论内容包含 code% 的文章...
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
})->get();

// 获取至少有十条评论且内容包含 code% 的文章...
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
}, '>=', 10)->get();

注意,Eloquent 目前不支持跨数据库的关联存在性查询,关联必须存在于同一个数据库中。

多对多关系的存在性查询

whereAttachedTo 方法可用于查询与某个模型或模型集合存在多对多关联的模型:

$users = User::whereAttachedTo($role)->get();

你也可以向 whereAttachedTo 方法传入一个集合,此时 Laravel 会检索与集合中任意模型相关联的模型:

$tags = Tag::whereLike('name', '%laravel%')->get();

$posts = Post::whereAttachedTo($tags)->get();

内联关联存在性查询

如果你只想用单个简单的条件查询关联关系的存在性,可以使用 whereRelationorWhereRelationwhereMorphRelationorWhereMorphRelation 方法。例如,查询所有未批准评论的文章:

use App\Models\Post;

$posts = Post::whereRelation('comments', 'is_approved', false)->get();

当然,和查询构建器的 where 方法一样,你也可以指定操作符:

$posts = Post::whereRelation(
    'comments', 'created_at', '>=', now()->subHour()
)->get();

查询关联是否不存在

在检索模型记录时,你可能希望根据关联关系的不存在来限制结果。例如,假设你想获取没有任何评论的所有博客文章,可以将关联关系的名称传递给 doesntHaveorDoesntHave 方法:

use App\Models\Post;

$posts = Post::doesntHave('comments')->get();

如果需要更强大的功能,可以使用 whereDoesntHaveorWhereDoesntHave 方法,为 doesntHave 查询添加额外的约束条件,例如检查评论内容:

use Illuminate\Database\Eloquent\Builder;

$posts = Post::whereDoesntHave('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
})->get();

你也可以使用 “点” 语法对嵌套关系执行查询。例如,下面的查询将获取所有没有评论的文章,以及那些有评论但评论作者未被禁用的文章:

use Illuminate\Database\Eloquent\Builder;

$posts = Post::whereDoesntHave('comments.author', function (Builder $query) {
    $query->where('banned', 1);
})->get();

查询 Morph To 关联

要查询 “morph to” 关系的存在情况,可以使用 whereHasMorphwhereDoesntHaveMorph 方法。这些方法的第一个参数是关系名称,接着传入希望包含在查询中的关联模型名称,最后可以提供一个闭包来自定义关系查询:

use App\Models\Comment;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Builder;

// 获取与标题类似于 code% 的帖子或视频相关的评论…
$comments = Comment::whereHasMorph(
    'commentable',
    [Post::class, Video::class],
    function (Builder $query) {
        $query->where('title', 'like', 'code%');
    }
)->get();

// 获取与标题不类似于 code% 的帖子相关的评论…
$comments = Comment::whereDoesntHaveMorph(
    'commentable',
    Post::class,
    function (Builder $query) {
        $query->where('title', 'like', 'code%');
    }
)->get();

有时你可能需要根据关联多态模型的“类型”添加查询约束。传给 whereHasMorph 的闭包可以接收 $type 作为第二个参数,用来检查正在构建的查询类型:

use Illuminate\Database\Eloquent\Builder;

$comments = Comment::whereHasMorph(
    'commentable',
    [Post::class, Video::class],
    function (Builder $query, string $type) {
        $column = $type === Post::class ? 'content' : 'title';

        $query->where($column, 'like', 'code%');
    }
)->get();

有时你可能希望查询 “morph to” 关系父模型的子模型,可以使用 whereMorphedTowhereNotMorphedTo 方法,这些方法会自动确定给定模型的多态类型映射。第一个参数是 morphTo 关系名称,第二个参数是相关父模型:

$comments = Comment::whereMorphedTo('commentable', $post)
    ->orWhereMorphedTo('commentable', $video)
    ->get();

查询所有关联模型

如果不想传递多态模型数组,可以使用 * 作为通配符,这会让 Laravel 从数据库中检索所有可能的多态类型,Laravel 会额外执行一次查询来完成此操作:

use Illuminate\Database\Eloquent\Builder;

$comments = Comment::whereHasMorph('commentable', '*', function (Builder $query) {
    $query->where('title', 'like', 'foo%');
})->get();

聚合相关模型

统计相关模型

有时你可能希望统计给定关系的关联模型数量,而无需实际加载这些模型。为此,可以使用 withCount 方法。withCount 会在结果模型上生成一个 {relation}_count 属性:

use App\Models\Post;

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

通过向 withCount 传递数组,你可以同时统计多个关系的数量,并为查询添加额外约束:

use Illuminate\Database\Eloquent\Builder;

$posts = Post::withCount([
    'votes',
    'comments' => function (Builder $query) {
        $query->where('content', 'like', 'code%');
    }
])->get();

echo $posts[0]->votes_count;
echo $posts[0]->comments_count;

你也可以为统计结果设置别名,从而在同一关系上进行多次计数:

use Illuminate\Database\Eloquent\Builder;

$posts = Post::withCount([
    'comments',
    'comments as pending_comments_count' => function (Builder $query) {
        $query->where('approved', false);
    },
])->get();

echo $posts[0]->comments_count;
echo $posts[0]->pending_comments_count;

延迟加载计数

使用 loadCount 方法,你可以在父模型已经被检索后,再加载关系的计数:

$book = Book::first();

$book->loadCount('genres');

如果需要为计数查询添加额外约束,可以传递一个以关系名为键、闭包为值的数组:

$book->loadCount([
    'reviews' => function (Builder $query) {
        $query->where('rating', 5);
    }
]);

计数与自定义 select 查询

如果将 withCountselect 语句结合使用,请确保在 select 方法之后调用 withCount

$posts = Post::select(['title', 'body'])
    ->withCount('comments')
    ->get();

其他聚合函数

除了 withCount 方法外,Eloquent 还提供了 withMinwithMaxwithAvgwithSumwithExists 方法。这些方法会在结果模型上生成 {relation}_{function}_{column} 属性:

use App\Models\Post;

$posts = Post::withSum('comments', 'votes')->get();

foreach ($posts as $post) {
    echo $post->comments_sum_votes;
}

如果希望使用自定义名称来访问聚合函数的结果,可以指定别名:

$posts = Post::withSum('comments as total_comments', 'votes')->get();

foreach ($posts as $post) {
    echo $post->total_comments;
}

loadCount 方法类似,这些聚合方法也提供延迟加载版本,可以对已经检索到的 Eloquent 模型执行额外的聚合操作:

$post = Post::first();

$post->loadSum('comments', 'votes');

如果将这些聚合方法与 select 语句结合使用,请确保在 select 方法之后调用聚合方法:

$posts = Post::select(['title', 'body'])
    ->withExists('comments')
    ->get();

统计 Morph To 关联模型

如果你希望对 “morph to” 关系进行预加载,同时获取该关系可能返回的各类实体的关联模型数量,可以结合 with 方法和 morphTo 关系的 morphWithCount 方法来实现。

在这个例子中,假设 PhotoPost 模型都可以创建 ActivityFeed 模型。ActivityFeed 模型定义了一个名为 parentable 的 “morph to” 关系,用于获取给定 ActivityFeed 实例的父模型(可能是 PhotoPost)。此外,假设 Photo 模型有多个 Tag,而 Post 模型有多个 Comment

现在,假设我们希望检索 ActivityFeed 实例,并预加载每个 ActivityFeed 实例的 parentable 父模型。同时,我们希望获取每个父照片关联的标签数量,以及每个父文章关联的评论数量:

use Illuminate\Database\Eloquent\Relations\MorphTo;

$activities = ActivityFeed::with([
    'parentable' => function (MorphTo $morphTo) {
        $morphTo->morphWithCount([
            Photo::class => ['tags'],
            Post::class => ['comments'],
        ]);
    }
])->get();

延迟加载计数

假设我们已经检索了一组 ActivityFeed 模型,现在希望为这些 ActivityFeed 实例关联的各类 parentable 父模型加载嵌套关系计数,可以使用 loadMorphCount 方法:

$activities = ActivityFeed::with('parentable')->get();

$activities->loadMorphCount('parentable', [
    Photo::class => ['tags'],
    Post::class => ['comments'],
]);

预加载

在访问 Eloquent 关系作为属性时,相关模型会被“延迟加载”。也就是说,关系数据只有在你首次访问该属性时才会被实际加载。然而,Eloquent 也可以在查询父模型时“预加载”关系。预加载可以有效缓解 “N + 1” 查询问题。为了说明 N + 1 查询问题,假设有一个 Book 模型,它属于 Author 模型:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Book extends Model
{
    /**
     * 获取撰写该书的作者。
     */
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }
}

现在,假设我们想要检索所有书籍及其作者:

use App\Models\Book;

$books = Book::all();

foreach ($books as $book) {
    echo $book->author->name;
}

上面的循环会先执行一条查询以获取所有书籍,然后针对每本书执行另一条查询以获取作者信息。比如如果有 25 本书,这段代码会执行 26 条查询:1 条获取书籍的查询 + 25 条获取作者的查询。

幸运的是,我们可以使用预加载将操作减少到仅两条查询。构建查询时,可以使用 with 方法指定要预加载的关系:

$books = Book::with('author')->get();

foreach ($books as $book) {
    echo $book->author->name;
}

此时只会执行两条查询:一条获取所有书籍,另一条获取所有书籍的作者:

select * from books;

select * from authors where id in (1, 2, 3, 4, 5, ...);

预加载多个关系

有时你可能需要预加载多个不同的关系,只需将关系名称作为数组传给 with 方法:

$books = Book::with(['author', 'publisher'])->get();

嵌套预加载

如果需要预加载关系的子关系,可以使用“点”语法。例如,预加载书籍的作者及其个人联系人:

$books = Book::with('author.contacts')->get();

或者通过嵌套数组指定多个嵌套关系,更适合同时预加载多个嵌套关系:

$books = Book::with([
    'author' => [
        'contacts',
        'publisher',
    ],
])->get();

预加载 morphTo 关系的嵌套关系

如果希望预加载 morphTo 关系及其返回实体的嵌套关系,可以结合 with 方法和 morphTo 关系的 morphWith 方法。示例模型:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class ActivityFeed extends Model
{
    /**
     * 获取该动态记录的父模型。
     */
    public function parentable(): MorphTo
    {
        return $this->morphTo();
    }
}

假设 EventPhotoPost 模型都可以创建 ActivityFeed 模型,且 Event 属于 CalendarPhoto 有多个 TagPost 属于 Author。可以这样预加载所有父模型及其嵌套关系:

use Illuminate\Database\Eloquent\Relations\MorphTo;

$activities = ActivityFeed::query()
    ->with(['parentable' => function (MorphTo $morphTo) {
        $morphTo->morphWith([
            Event::class => ['calendar'],
            Photo::class => ['tags'],
            Post::class => ['author'],
        ]);
    }])->get();

预加载指定列

有时并不需要关系的所有列,Eloquent 允许指定只获取关系的部分列:

$books = Book::with('author:id,name,book_id')->get();

使用时应确保包含 id 列和任何相关的外键列。

默认预加载

如果希望模型在每次查询时都预加载某些关系,可以在模型中定义 $with 属性:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Book extends Model
{
    /**
     * 总是需要预加载的关系。
     *
     * @var array
     */
    protected $with = ['author'];

    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }

    public function genre(): BelongsTo
    {
        return $this->belongsTo(Genre::class);
    }
}
  • 若希望在单次查询中移除 $with 中的某个关系,可使用 without 方法:
$books = Book::without('author')->get();
  • 若希望在单次查询中覆盖 $with 中的所有关系,可使用 withOnly 方法:
$books = Book::withOnly('genre')->get();

限制预加载

有时你可能希望在进行预加载时,为预加载的查询指定额外的条件。你可以通过向 with 方法传入一个关系数组来实现,其中数组的键为关系名,值为一个闭包函数,用于对预加载查询添加约束条件:

use App\Models\User;

$users = User::with(['posts' => function ($query) {
    $query->where('title', 'like', '%code%');
}])->get();

在这个示例中,Eloquent 只会预加载 title 列包含 code 的帖子。你还可以调用查询构建器的其他方法进一步定制预加载操作:

$users = User::with(['posts' => function ($query) {
    $query->orderBy('created_at', 'desc');
}])->get();

为 morphTo 关系约束预加载

如果你预加载的是 morphTo 关系,Eloquent 会针对每种类型的关联模型执行多条查询。你可以使用 MorphTo 关系的 constrain 方法为每条查询添加约束:

use Illuminate\Database\Eloquent\Relations\MorphTo;

$comments = Comment::with(['commentable' => function (MorphTo $morphTo) {
    $morphTo->constrain([
        Post::class => function ($query) {
            $query->whereNull('hidden_at');
        },
        Video::class => function ($query) {
            $query->where('type', 'educational');
        },
    ]);
}])->get();

在这个示例中,Eloquent 只会预加载未隐藏的帖子和类型为 "educational" 的视频。

在关系存在的同时约束预加载

有时你可能希望在检查关系存在的同时,仅加载符合特定条件的关联模型。例如,你希望只获取那些拥有符合条件的子帖子的用户,同时预加载这些帖子。你可以使用 withWhereHas 方法实现:

use App\Models\User;

$users = User::withWhereHas('posts', function ($query) {
    $query->where('featured', true);
})->get();

上述代码会只获取拥有 featuredtrue 帖子的用户,并同时预加载这些符合条件的帖子。

延迟预加载

有时,你可能需要在父模型已经被检索之后再进行关系的预加载。例如,这在你需要动态决定是否加载关联模型时非常有用:

use App\Models\Book;

$books = Book::all();

if ($condition) {
    $books->load('author', 'publisher');
}

如果你需要为预加载查询设置额外的约束条件,可以传入一个以关系名为键的数组,数组值为闭包函数,闭包接收查询实例:

$author->load(['books' => function ($query) {
    $query->orderBy('published_date', 'asc');
}]);

如果你只想在关系尚未被加载时再进行加载,可以使用 loadMissing 方法:

$book->loadMissing('author');

嵌套延迟预加载与 morphTo 关系

如果你希望预加载一个 morphTo 关系,同时也预加载该关系可能返回的各种实体的嵌套关系,可以使用 loadMorph 方法。

此方法的第一个参数为 morphTo 关系名,第二个参数为模型/关系对的数组。举个例子:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class ActivityFeed extends Model
{
    /**
     * 获取活动动态记录的父模型。
     */
    public function parentable(): MorphTo
    {
        return $this->morphTo();
    }
}

假设 EventPhotoPost 模型都可以创建 ActivityFeed 模型。此外,Event 模型属于 CalendarPhoto 模型关联 TagPost 模型属于 Author

使用这些模型定义和关系,我们可以检索 ActivityFeed 实例,并预加载所有父模型及其各自的嵌套关系:

$activities = ActivityFeed::with('parentable')
    ->get()
    ->loadMorph('parentable', [
        Event::class => ['calendar'],
        Photo::class => ['tags'],
        Post::class => ['author'],
    ]);

这样,ActivityFeed 实例会一次性加载父模型及其相关的嵌套关系,避免了 N + 1 查询问题。

自动预加载

此功能目前处于 测试阶段(beta),以收集社区反馈。功能的行为和实现可能会在小版本更新中发生变化。

在许多情况下,Laravel 可以自动对你访问的关系进行预加载。要启用自动预加载关系功能,你应在应用的 AppServiceProviderboot 方法中调用 Model::automaticallyEagerLoadRelationships

use Illuminate\Database\Eloquent\Model;

/**
 * 引导应用程序服务。
 */
public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

启用此功能后,Laravel 会尝试自动加载任何尚未被加载的关系。例如,考虑以下场景:

use App\Models\User;

$users = User::all();

foreach ($users as $user) {
    foreach ($user->posts as $post) {
        foreach ($post->comments as $comment) {
            echo $comment->content;
        }
    }
}

通常,上述代码会为每个用户执行一次查询来获取他们的帖子,并为每个帖子执行一次查询来获取评论。但启用 automaticallyEagerLoadRelationships 后,当你访问任意用户的帖子时,Laravel 会自动对整个用户集合中的所有用户的帖子进行延迟预加载。同样,当你访问任意已检索帖子下的评论时,所有帖子对应的评论都会被延迟预加载。

如果你不希望全局启用自动预加载,也可以仅对单个 Eloquent 集合实例启用此功能,通过在集合上调用 withRelationshipAutoloading 方法实现:

$users = User::where('vip', true)->get();

return $users->withRelationshipAutoloading();

这样,你就可以针对特定集合灵活使用自动预加载功能,而无需全局开启。

禁止延迟加载

如前所述,预加载(eager loading)关系通常可以显著提升应用的性能。因此,如果你希望,可以指示 Laravel 始终禁止关系的延迟加载(lazy loading)。为此,你可以调用基础 Eloquent 模型类提供的 preventLazyLoading 方法。通常,你应在应用的 AppServiceProviderboot 方法中调用该方法:

use Illuminate\Database\Eloquent\Model;

/**
 * 引导应用程序服务。
 */
public function boot(): void
{
    // 仅在非生产环境中禁用 lazy loading
    Model::preventLazyLoading(! $this->app->isProduction());
}

启用后,当应用尝试对任何 Eloquent 关系执行延迟加载时,Eloquent 会抛出 Illuminate\Database\LazyLoadingViolationException 异常。

你还可以使用 handleLazyLoadingViolationsUsing 方法自定义延迟加载违规的处理行为。例如,你可以让延迟加载违规 仅记录日志,而不是通过异常中断应用执行:

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    $class = $model::class;

    info("尝试对模型 [{$class}] 的关系 [{$relation}] 进行延迟加载。");
});

这样,你既可以在开发环境中发现潜在的性能问题,又不会影响生产环境的正常运行。

插入与更新关联模型

save 方法

Eloquent 提供了便捷的方法来向关系中添加新模型。例如,你可能需要给某篇文章添加一条新的评论。与其手动设置 Comment 模型的 post_id 属性,不如使用关系的 save 方法来插入评论:

use App\Models\Comment;
use App\Models\Post;

$comment = new Comment(['message' => 'A new comment.']);

$post = Post::find(1);

$post->comments()->save($comment);

注意,我们并未将 comments 作为动态属性访问,而是调用了 comments 方法以获取关系实例。save 方法会自动为新的 Comment 模型添加合适的 post_id 值。

如果需要保存多个关联模型,可以使用 saveMany 方法:

$post = Post::find(1);

$post->comments()->saveMany([
    new Comment(['message' => 'A new comment.']),
    new Comment(['message' => 'Another new comment.']),
]);

savesaveMany 方法会持久化给定的模型实例,但不会将新保存的模型添加到已加载到父模型内存中的关系中。如果之后还需要访问该关系,可以使用 refresh 方法重新加载模型及其关系:

$post->comments()->save($comment);

$post->refresh();

// 包括新保存的评论在内的所有评论...
$post->comments;

递归保存模型及其关系

如果希望保存模型及其所有关联关系,可以使用 push 方法。例如,下面的示例会保存 Post 模型,同时保存它的评论以及评论对应的作者:

$post = Post::find(1);

$post->comments[0]->message = 'Message';
$post->comments[0]->author->name = 'Author Name';

$post->push();

如果希望在保存模型及其关联关系时 不触发任何事件,可以使用 pushQuietly 方法:

$post->pushQuietly();

create 方法

除了 savesaveMany 方法之外,你还可以使用 create 方法。该方法接收一个属性数组,创建模型并将其插入数据库。savecreate 的区别在于:save 接受一个完整的 Eloquent 模型实例,而 create 接受一个普通的 PHP 数组。create 方法会返回新创建的模型实例:

use App\Models\Post;

$post = Post::find(1);

$comment = $post->comments()->create([
    'message' => 'A new comment.',
]);

如果需要创建多个关联模型,可以使用 createMany 方法:

$post = Post::find(1);

$post->comments()->createMany([
    ['message' => 'A new comment.'],
    ['message' => 'Another new comment.'],
]);

createQuietlycreateManyQuietly 方法可以在不触发任何事件的情况下创建模型:

$user = User::find(1);

$user->posts()->createQuietly([
    'title' => 'Post title.',
]);

$user->posts()->createManyQuietly([
    ['title' => 'First post.'],
    ['title' => 'Second post.'],
]);

此外,你还可以使用 findOrNewfirstOrNewfirstOrCreateupdateOrCreate 方法在关系上创建或更新模型。

在使用 create 方法之前,请务必查看 批量赋值(mass assignment) 的相关文档,以确保属性可以被安全填充。

Belongs To 关联

如果你想将子模型关联到一个新的父模型,可以使用 associate 方法。在下面的示例中,User 模型定义了一个 belongsTo 关系指向 Account 模型。associate 方法会在子模型上设置相应的外键:

use App\Models\Account;

$account = Account::find(10);

$user->account()->associate($account);

$user->save();

如果你想将父模型从子模型中移除,可以使用 dissociate 方法。该方法会将关系的外键设置为 null

$user->account()->dissociate();

$user->save();

多对多关联

附加 / 移除关联

Eloquent 还提供了便捷的方法来处理多对多关系。例如,假设一个用户可以拥有多个角色,而一个角色也可以属于多个用户。你可以使用 attach 方法通过在中间表中插入记录来将角色附加到用户:

use App\Models\User;

$user = User::find(1);

$user->roles()->attach($roleId);

在附加关系时,你也可以传递一个数组,将额外数据插入中间表:

$user->roles()->attach($roleId, ['expires' => $expires]);

有时可能需要从用户中移除角色。要删除多对多关系记录,可以使用 detach 方法。detach 方法会从中间表中删除相应记录,但两个模型本身仍保留在数据库中:

// 从用户移除单个角色
$user->roles()->detach($roleId);

// 移除用户的所有角色
$user->roles()->detach();

为了方便,attachdetach 也支持数组形式的 ID:

$user = User::find(1);

$user->roles()->detach([1, 2, 3]);

$user->roles()->attach([
    1 => ['expires' => $expires],
    2 => ['expires' => $expires],
]);

同步关联

你可以使用 sync 方法来构建多对多关联。sync 方法接受一个 ID 数组,将其放入中间表中。任何不在给定数组中的 ID 将从中间表中移除。执行完成后,中间表中仅保留给定数组中的 ID:

$user->roles()->sync([1, 2, 3]);

你也可以为 ID 传递额外的中间表数据:

$user->roles()->sync([1 => ['expires' => true], 2, 3]);

如果希望对所有同步的模型 ID 插入相同的中间表数据,可以使用 syncWithPivotValues 方法:

$user->roles()->syncWithPivotValues([1, 2, 3], ['active' => true]);

如果不希望移除给定数组中缺失的现有 ID,可以使用 syncWithoutDetaching 方法:

$user->roles()->syncWithoutDetaching([1, 2, 3]);

切换关联

多对多关系还提供 toggle 方法,用于“切换”指定关联模型 ID 的附加状态。如果给定 ID 当前已附加,则会被移除;如果当前未附加,则会被附加:

$user->roles()->toggle([1, 2, 3]);

你也可以为 ID 传递额外的中间表数据:

$user->roles()->toggle([
    1 => ['expires' => true],
    2 => ['expires' => true],
]);

更新中间表记录

如果需要更新多对多关系中已有的中间表记录,可以使用 updateExistingPivot 方法。该方法接受中间表的外键和一个属性数组来更新记录:

$user = User::find(1);

$user->roles()->updateExistingPivot($roleId, [
    'active' => false,
]);

更新父模型时间戳

当一个模型定义了 belongsTobelongsToMany 关系指向另一个模型时,例如 Comment 属于 Post,有时希望在子模型被更新时自动更新父模型的时间戳。

例如,当 Comment 模型被更新时,你可能希望自动“触碰”(touch)其所属 Post 模型的 updated_at 时间戳,使其更新为当前日期和时间。为此,你可以在子模型中添加一个 touches 属性,包含那些在子模型更新时应更新 updated_at 时间戳的关系名称:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Comment extends Model
{
    /**
     * 需要自动更新时间戳的关系。
     *
     * @var array
     */
    protected $touches = ['post'];

    /**
     * 获取评论所属的文章。
     */
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}
父模型的时间戳仅会在子模型通过 Eloquent 的 save 方法更新时被修改。