引言

数据库表之间通常彼此关联。例如,一篇博客文章可能拥有许多评论,或者一个订单可能与下单的用户相关。Eloquent 让这类关系的管理与操作变得简单,并支持多种常见关系:

  • 一对一
  • 一对多
  • 多对多
  • 通过一个关系
  • 通过多个关系
  • 一对一(多态)
  • 一对多(多态)
  • 多对多(多态)

定义关系

Eloquent 关系被定义为 Eloquent 模型类上的方法。由于关系也可充当强大的查询构建器,将关系定义为方法可提供强大的方法链式调用与查询能力。例如,我们可以在 posts 关系上链式添加更多查询约束:

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

不过,在深入使用关系之前,让我们先学习如何定义 Eloquent 支持的每种关系类型。

一对一 / Has One

一对一关系是一种非常基础的数据库关系类型。例如,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 方法的第一个参数是相关模型类的名称。一旦定义了关系,我们就可以使用 Eloquent 的动态属性来获取相关记录。动态属性允许你像访问模型上定义的属性一样访问关系方法:

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

Eloquent 会根据父模型名称确定关系的外键。在此示例中,系统会自动假定 Phone 模型具有 user_id 外键。如果你希望覆盖此约定,可以向 hasOne 方法传入第二个参数:

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

此外,Eloquent 假定外键的值应与父模型的主键列相匹配。换句话说,Eloquent 会在 Phone 记录的 user_id 列中查找用户的 id 列值。如果你希望该关系使用不同于 id 或模型主键的主键值,可以向 hasOne 方法传入第三个参数:

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

定义关系的反向关系

这样,我们就可以从 User 模型访问 Phone 模型。接下来,我们在 Phone 模型上定义一个关系,以便访问拥有该电话的用户。可以使用 belongsTo 方法定义 hasOne 关系的反向关系:

<?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 会尝试查找 id 与 Phone 模型上 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');
}

一对多 / Has Many

一对多关系用于定义单个模型作为父模型,且拥有一个或多个子模型的关系。例如,一篇博客文章可能拥有无限数量的评论。与所有其他 Eloquent 关系一样,一对多关系通过在 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 的预加载,在遍历子模型时尝试从子模型访问父模型,也可能出现“N + 1”查询问题:

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

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

在上面的示例中,出现了“N + 1”查询问题,因为即便为每个 Post 模型预加载了评论,Eloquent 也不会自动在每个子 Comment 模型上填充父 Post 模型。

如果你希望 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();

一对多(反向)/ Belongs To

现在我们已经可以访问一篇文章的所有评论,接下来定义一个关系,让评论可以访问其父文章。要定义 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);
    }
}

定义好关系后,我们可以通过访问 post “动态关系属性”来检索评论所属的父级文章:

use App\Models\Comment;

$comment = Comment::find(1);

return $comment->post->title;

在上面的示例中,Eloquent 将尝试查找一个 Post 模型,该模型的 id 与 Comment 模型上的 post_id 列相匹配。

Eloquent 会通过检查关系方法的名称,并在该方法名后加上 _,再跟上父级模型主键列的名称,来确定默认的外键名称。因此,在此示例中,Eloquent 会假定 Post 模型在 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');
}

默认模型

belongsTo、hasOne、hasOneThrough 和 morphOne 关系允许你定义一个默认模型,当给定的关系为 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';
    });
}

查询 Belongs To 关系

当查询“属于”(belongs to)关系的子项时,你可以手动构建 where 子句来检索对应的 Eloquent 模型:

use App\Models\Post;

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

不过,你可能会发现使用 whereBelongsTo 方法更为便捷,该方法会自动为给定的模型确定正确的关系和外键:

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

你也可以向 whereBelongsTo 方法提供一个集合(collection)实例。这样做时,Laravel 将检索属于该集合中任意父级模型的模型:

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

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

默认情况下,Laravel 会根据模型的类名来确定与给定模型关联的关系;不过,你也可以通过将关系名称作为第二个参数传递给 whereBelongsTo 方法,手动指定关系名称:

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

一对多中的单条记录(Has One of Many)

有时,一个模型可能有多个关联模型,但你希望便捷地检索该关系中“最新”或“最旧”的关联模型。例如,User 模型可能与多个 Order 模型相关联,但你想定义一种便捷的方式,以便与用户下的最新订单进行交互。你可以使用 hasOne 关系类型结合 ofMany 方法来实现这一点:

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

同样,你可以定义一个方法,用于检索关系中“最旧”或第一条关联模型:

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

默认情况下,latestOfMany 和 oldestOfMany 方法会根据模型的主键(必须可排序)来检索最新或最旧的关联模型。不过,有时你可能希望通过不同的排序条件,从更大的关系中检索单个模型。

例如,使用 ofMany 方法,你可以检索用户金额最高的订单。ofMany 方法接受可排序的列作为其第一个参数,以及查询关联模型时要应用的聚合函数(min 或 max):

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

由于 PostgreSQL 不支持对 UUID 列执行 MAX 函数,因此目前无法将一对多关系与 PostgreSQL 的 UUID 列结合使用。

将“多”关系转换为“一对一”关系

在使用 latestOfMany、oldestOfMany 或 ofMany 方法检索单个模型时,通常你已经为同一个模型定义了“多对一”关系。为了方便起见,Laravel 允许你通过调用该关系上的 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();
}

高级的“一对多中的单条记录”关系

可以构建更高级的关系。例如,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 模型。虽然在数据库中,维修工与车主之间没有直接关系,但维修工可以通过 Car 模型访问车主。我们来看看定义这种关系所需的表:

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
{
    /**
     * Get the car's owner.
     */
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(Owner::class, Car::class);
    }
}

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

或者,如果在该关系涉及的所有模型上都已经定义了相关关系,您可以通过调用 through 方法并传入这些关系的名称,流畅地定义一个“通过一对多”关系。例如,如果 Mechanic 模型具有 cars 关系,且 Car 模型具有 owner 关系,您可以按如下方式定义一个连接技工与车主的“通过一对多”关系:

// String based syntax...
return $this->through('cars')->has('owner');

// Dynamic syntax...
return $this->throughCars()->hasOwner();

键约定

在执行关系查询时,会使用典型的 Eloquent 外键约定。如果您想自定义该关系的键,可以将其作为第三和第四个参数传递给 hasOneThrough 方法。第三个参数是中间模型上外键的名称。第四个参数是最终模型上外键的名称。第五个参数是本地键,而第六个参数是中间模型的本地键:

class Mechanic extends Model
{
    /**
     * Get the car's owner.
     */
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(
            Owner::class,
            Car::class,
            'mechanic_id', // Foreign key on the cars table...
            'car_id', // Foreign key on the owners table...
            'id', // Local key on the mechanics table...
            'id' // Local key on the cars table...
        );
    }
}

或者,正如之前讨论的那样,如果在该关系涉及的所有模型上都已经定义了相关关系,您可以通过调用 through 方法并传入这些关系的名称,流畅地定义一个“通过一对多”关系。这种方法的优势在于可以复用已在现有关系上定义的键约定:

// String based syntax...
return $this->through('cars')->has('owner');

// Dynamic syntax...
return $this->throughCars()->hasOwner();

通过多对关系

“通过多对”关系提供了一种便捷的方式,可通过中间关系访问远距离关系。例如,假设我们正在构建一个类似 Laravel Cloud 的部署平台。一个 Application 模型可能会通过中间的 Environment 模型访问许多 Deployment 模型。使用此示例,您可以轻松收集特定应用的所有部署记录。我们来看看定义此关系所需的表:

applications
    id - integer
    name - string

environments
    id - integer
    application_id - integer
    name - string

deployments
    id - integer
    environment_id - integer
    commit_hash - string

既然我们已经研究了该关系的表结构,现在来在 Application 模型上定义该关系:

<?php

namespace App\Models;

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

class Application extends Model
{
    /**
     * Get all of the deployments for the application.
     */
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(Deployment::class, Environment::class);
    }
}

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

或者,如果在该关系涉及的所有模型上都已经定义了相关关系,您可以通过调用 through 方法并传入这些关系的名称,流畅地定义一个“通过多对”关系。例如,如果 Application 模型具有 environments 关系,且 Environment 模型具有 deployments 关系,您可以按如下方式定义一个连接应用与部署的“通过多对”关系:

// String based syntax...
return $this->through('environments')->has('deployments');

// Dynamic syntax...
return $this->throughEnvironments()->hasDeployments();

尽管 Deployment 模型的表不包含 application_id 列,但 hasManyThrough 关系可通过 $application->deployments 访问应用的部署记录。为了检索这些模型,Eloquent 会检查中间 Environment 模型表上的 application_id 列。找到相关环境 ID 后,会使用这些 ID 对 Deployment 模型表进行查询。

键约定

在执行关系查询时,会使用典型的 Eloquent 外键约定。如果您想自定义该关系的键,可以将其作为第三和第四个参数传递给 hasManyThrough 方法。第三个参数是中间模型上外键的名称。第四个参数是最终模型上外键的名称。第五个参数是本地键,而第六个参数是中间模型的本地键:

class Application extends Model
{
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(
            Deployment::class,
            Environment::class,
            'application_id', // Foreign key on the environments table...
            'environment_id', // Foreign key on the deployments table...
            'id', // Local key on the applications table...
            'id' // Local key on the environments table...
        );
    }
}

或者,正如之前讨论的那样,如果在该关系涉及的所有模型上都已经定义了相关关系,您可以通过调用 through 方法并传入这些关系的名称,流畅地定义一个“通过多对”关系。这种方法的优势在于可以复用已在现有关系上定义的键约定:

// String based syntax...
return $this->through('environments')->has('deployments');

// Dynamic syntax...
return $this->throughEnvironments()->hasDeployments();

限定关系

通常会向模型添加额外的方法来约束关系。例如,您可能会向 User 模型添加一个 featuredPosts 方法,该方法会通过额外的 where 约束来约束更宽泛的 posts 关系:

<?php

namespace App\Models;

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

class User extends Model
{
    /**
     * Get the user's posts.
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class)->latest();
    }

    /**
     * Get the user's featured posts.
     */
    public function featuredPosts(): HasMany
    {
        return $this->posts()->where('featured', true);
    }
}

然而,如果您尝试通过 featuredPosts 方法创建模型,其 featured 属性将不会被设置为 true。如果您想通过关系方法创建模型,同时指定应添加到通过该关系创建的所有模型中的属性,可以在构建关系查询时使用 withAttributes 方法:

/**
 * Get the user's featured posts.
 */
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);

多对多关系

多对多关系比 hasOne 和 hasMany 关系稍微复杂一些。多对多关系的一个例子是,一个用户拥有多个角色,而这些角色也在应用中被其他用户共享。例如,一个用户可能被分配“作者”和“编辑”角色;然而,这些角色也可能被分配给其他用户。因此,一个用户拥有多个角色,一个角色也拥有多个用户。

表结构

要定义这种关系,需要三个数据库表:users、roles 和 role_user。role_user 表是根据相关模型名称的字母顺序派生而来的,包含 user_id 和 role_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');

除了自定义中间表的名称,还可以通过向 belongsToMany 方法传入更多参数,来自定义表中键的列名。第三个参数是你正在定义关系的模型的外键名称,第四个参数是你要关联的模型的外键名称:

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_at 和 updated_at 时间戳,请在定义关系时调用 withTimestamps 方法:

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

使用 Eloquent 自动维护时间戳的中间表,必须同时具备 created_at 和 updated_at 时间戳列。

自定义 pivot 属性名称

如前所述,中间表中的属性可以通过 pivot 属性在模型上访问。不过,你可以自由自定义该属性的名称,以便更好地反映其在应用程序中的用途。

例如,如果你的应用程序包含可以订阅播客的用户,那么用户与播客之间很可能存在多对多关系。在这种情况下,你可能希望将中间表属性重命名为 subscription,而不是 pivot。这可以通过在定义关系时使用 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;
}

通过中间表列过滤查询

在定义关系时,你还可以使用 wherePivot、wherePivotIn、wherePivotNotIn、wherePivotBetween、wherePivotNotBetween、wherePivotNull 和 wherePivotNotNull 方法,来过滤 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 和 orderByPivotDesc 方法,对 belongsToMany 关系查询返回的结果进行排序。在下面的示例中,我们将获取用户的所有最新徽章:

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

定义自定义中间表模型

如果你想定义一个自定义模型来表示多对多关系的中间表,可以在定义关系时调用 using 方法。自定义中间表模型让你有机会在中间表模型上定义额外的行为,例如方法和类型转换。

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

<?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
{
    // ...
}

中间表模型不能使用 SoftDeletes 特性。如果你需要对中间表记录进行软删除,请考虑将你的中间表模型转换为真正的 Eloquent 模型。

自定义中间表模型与自增 ID

如果你定义了一个使用自定义中间表模型的多对多关系,且该中间表模型具有自增主键,那么你应该确保你的自定义中间表模型类使用 Table 属性,并将 incrementing 设置为 true:

use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Relations\Pivot;

#[Table(incrementing: true)]
class RoleUser extends Pivot
{
    // ...
}

自动填充中间表关系

当自定义中间表模型为声明模型和关联模型定义了 belongsTo 关系时,你可以调用 chaperone 方法,以便在每个中间表模型上自动填充这些关系。这样可以避免在通过中间表访问模型时执行额外的查询:

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

class RoleUser extends Pivot
{
    public function role(): BelongsTo
    {
        return $this->belongsTo(Role::class);
    }

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

class Role extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->using(RoleUser::class)
            ->chaperone();
    }
}

Eloquent 会尝试推断中间表关系的名称。如果你的中间表模型使用了非标准名称,请将声明模型和关联模型的关系名称传递给 chaperone 方法:

return $this->belongsToMany(User::class)
    ->using(RoleUser::class)
    ->chaperone(declaring: 'role', related: 'user');

多态关系

多态关系允许子模型通过单一关联属于不止一种类型的模型。例如,假设你正在开发一个允许用户分享博客文章和视频的应用。在这样的应用中,Comment 模型可能既属于 Post 模型,也属于 Video 模型。

一对一(多态)

表结构

一对一多态关系类似于典型的一对一关系;然而,子模型可以通过单一关联属于不止一种类型的模型。例如,博客 Post 和 User 可能与 Image 模型共享多态关系。使用一对一多态关系,你可以用一张独立的图片表来存储与文章和用户相关的图片。首先,我们来看看表结构:

posts
    id - integer
    name - string

users
    id - integer
    name - string

images
    id - integer
    url - string
    imageable_type - string
    imageable_id - integer

请注意 images 表中的 imageable_id 和 imageable_type 列。imageable_id 列将存储文章或用户的 ID 值,而 imageable_type 列将存储父模型的类名。Eloquent 使用 imageable_type 列来确定在访问 imageable 关系时要返回哪种“类型”的父模型。在这种情况下,该列的值将是 App\Models\Post 或 App\Models\User。

模型结构

接下来,我们来看看构建此关系所需的模型定义:

<?php

namespace App\Models;

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

class Image extends Model
{
    /**
     * 获取父级可映像模型(用户或文章)。
     */
    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');
    }
}

获取关系

一旦定义了数据库表和模型,就可以通过模型访问这些关系。例如,要获取某篇文章的图片,我们可以访问 image 动态关系属性:

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 关系会根据拥有该图片的模型类型,返回 Post 或 User 实例。

键名约定

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

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

一对多(多态)

表结构

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

posts
    id - integer
    title - string
    body - text

videos
    id - integer
    title - string
    url - string

comments
    id - integer
    body - text
    commentable_type - string
    commentable_id - integer

模型结构

接下来,我们来看看构建此关系所需的模型定义:

<?php

namespace App\Models;

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

class Comment extends Model
{
    /**
     * 获取父级可评论模型(文章或视频)。
     */
    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');
    }
}

获取关系

定义好数据库表和模型后,即可通过模型的动态关系属性来访问这些关系。例如,要获取一篇文章的所有评论,可以使用 comments 动态属性:

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 关系会返回 Post 或 Video 实例,具体取决于评论的父模型是哪种类型的模型。

自动向子模型填充父模型

即便使用了 Eloquent 的预加载,如果在遍历子模型时尝试从子模型访问父模型,也可能会产生“N + 1”查询问题:

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

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

在上面的示例中,就引入了一个“N + 1”查询问题,因为即便为每个 Post 模型预加载了评论,Eloquent 也不会自动将父模型 Post 填充到每个子 Comment 模型上。

如果你希望 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();

多态一对一(One of Many)

有时,一个模型可能关联多个相关模型,但你希望便捷地检索出该关系中“最新”或“最早”的相关模型。例如,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();
}

默认情况下,latestOfMany 和 oldestOfMany 方法会根据模型的主键检索最新或最早的相关模型,该主键必须是可排序的。然而,有时你可能希望使用不同的排序条件,从更大的关系中检索出单个模型。

例如,使用 ofMany 方法,你可以检索出用户“点赞数最多”的图片。ofMany 方法的第一个参数为可排序列,第二个参数为查询相关模型时要应用的聚合函数(min 或 max):

/**
 * 获取用户最受欢迎的图片。
 */
public function bestImage(): MorphOne
{
    return $this->morphOne(Image::class, 'imageable')->ofMany('likes', 'max');
}

还可以构建更复杂的“多态一对一(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_type - string
    taggable_id - integer

在深入学习多态多对多关系之前,阅读常规多对多关系的文档对你会很有帮助。

模型结构

接下来,我们就可以在模型中定义关系了。Post 和 Video 模型都将包含一个 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 模型中,你需要为其每一种可能的父模型定义一个方法。因此,在此示例中,我们将定义 posts 方法和 videos 方法。这两个方法都应返回 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 模型上的 posts 或 videos 方法:

use App\Models\Tag;

$tag = Tag::find(1);

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

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

自定义多态类型

默认情况下,Laravel 会使用全限定类名来存储相关模型的“类型”。例如,对于上述一对多关系的示例,其中 Comment 模型可能属于 Post 或 Video 模型,那么默认的 commentable_type 将分别为 App\Models\Post 或 App\Models\Video。然而,你可能希望将这些值与应用的内部结构解耦。

例如,我们可以使用 post 和 video 这类简单的字符串,而不是直接用模型名作为“类型”。这样一来,即便模型被重命名,数据库中多态“类型”列的值依然有效:

use Illuminate\Database\Eloquent\Relations\Relation;

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

可以在 App\Providers\AppServiceProvider 类的 boot 方法中调用 enforceMorphMap 方法;如有需要,也可以创建一个独立的服务提供者。

你可以在运行时使用模型的 getMorphClass 方法来确定给定模型的形态别名。反之,你也可以使用 Relation::getMorphedModel 方法来确定与形态别名关联的全限定类名:

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。如你所见,or 子句指示查询返回票数大于 100 的任意文章。此时查询不再受限于特定用户:

select *
from posts
where user_id = ? and active = 1 or votes >= 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)

关系方法与动态属性

如果你不需要向 Eloquent 关系查询添加额外的约束,你可以像访问属性一样访问该关系。例如,继续使用我们的 User 和 Post 示例模型,我们可以像这样访问某用户的所有文章:

use App\Models\User;

$user = User::find(1);

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

动态关系属性会执行“延迟加载”,这意味着它们仅在被实际访问时才会加载关系数据。因此,开发人员通常使用预加载(eager loading)来提前加载那些已知会在模型加载后被访问的关系。预加载可大幅减少加载模型关系时必须执行的 SQL 查询数量。

查询关系是否存在

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

use App\Models\Post;

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

你也可以指定运算符和计数值,以便进一步自定义查询:

// 检索所有拥有三条及以上评论的文章……
$posts = Post::has('comments', '>=', 3)->get();

嵌套的 has 语句可以使用“点”号表示法来构建。例如,你可以检索所有至少有一条评论、且该评论至少有一张图片的文章:

// 检索所有拥有至少一条带有图片的评论的文章……
$posts = Post::has('comments.images')->get();

如果你需要更强的控制能力,可以使用 whereHas 和 orWhereHas 方法为你的 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();

内联关系存在性查询

如果你想通过将单一、简单的 where 条件附加到关系查询上,来查询关系是否存在,你可能会认为使用 whereRelation、orWhereRelation、whereMorphRelation 和 orWhereMorphRelation 方法更为便捷。例如,我们可以查询所有含有未批准评论的文章:

use App\Models\Post;

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

当然,就像调用查询构建器的 where 方法一样,你也可以指定运算符:

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

查询关系不存在

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

use App\Models\Post;

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

如果你需要更强的控制能力,可以使用 whereDoesntHave 和 orWhereDoesntHave 方法为你的 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)”的存在性,可以使用 whereHasMorph 和 whereDoesntHaveMorph 方法。这些方法的第一个参数为关系名称,第二个参数为希望包含在查询中的相关模型名称,最后,可以提供一个闭包来自定义关系查询:

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)”父级的子级。可以通过 whereMorphedTo 和 whereNotMorphedTo 方法来实现,这些方法会自动确定给定模型的正确多态类型映射。这些方法的第一个参数为 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);
}]);

关系计数与自定义选择语句

如果在组合 withCount 与 select 语句,请确保在调用 select 方法之后再调用 withCount:

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

其他聚合函数

除了 withCount 方法外,Eloquent 还提供了 withMin、withMax、withAvg、withSum 和 withExists 方法。这些方法会在生成的模型上添加 {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)”,以及该关系可能返回的不同实体的关联模型数量,可以使用 with 方法,并结合 morphTo 关系的 morphWithCount 方法。

在此示例中,假设 Photo 和 Post 模型可以创建 ActivityFeed 模型。我们假设 ActivityFeed 模型定义了一个名为 parentable 的“多态关系(morph to)”,通过该关系可以获取给定 ActivityFeed 实例的父级 Photo 或 Post 模型。此外,假设 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 模型,现在希望加载与这些活动流相关联的不同 parentable 模型的嵌套关系计数。可以使用 loadMorphCount 方法来实现:

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

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

预加载

当以属性的形式访问 Eloquent 关系时,相关的模型会被“惰性加载”。这意味着在首次访问该属性之前,关系数据实际上并不会被加载。不过,Eloquent 可以在查询父模型时“预加载”关系。预加载可以缓解“N + 1”查询问题。为了说明 N + 1 查询问题,请考虑一个“属于”Author 模型的 Book 模型:

<?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 次查询:一次用于检索原始书籍,另外 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();

另外,你也可以通过向 with 方法传入嵌套数组的方式来指定嵌套的预加载关系,在预加载多个嵌套关系时,这种方式十分便捷:

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

预加载 morphTo 关系

如果你想预加载 morphTo 关系,以及该关系可能返回的各种实体上的嵌套关系,可以使用 with 方法结合 morphTo 关系的 morphWith 方法。为便于说明该方法,让我们考虑以下模型:

<?php

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

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

在此示例中,假设 Event、Photo 和 Post 模型可以创建 ActivityFeed 模型。此外,假设 Event 模型属于 Calendar 模型,Photo 模型与 Tag 模型相关联,而 Post 模型属于 Author 模型。

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

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 将仅预加载未被隐藏的文章,以及 type 值为“educational”的视频。

通过关系存在性限制预加载

有时你可能需要检查关系是否存在,同时又根据相同的条件加载该关系。例如,你可能希望仅检索具有匹配给定查询条件的子级 Post 模型的 User 模型,同时也预加载这些匹配的文章。你可以使用 withWhereHas 方法来实现这一点:

use App\Models\User;

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

惰性预加载

有时你可能需要先检索出父模型,然后再预加载某个关系。例如,当你需要动态决定是否加载相关模型时,这就会很有用:

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 关系的名称,第二个参数接收模型/关系对的数组。为便于说明此方法,我们来看如下模型:

<?php

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

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

在本示例中,假设 Event、Photo 与 Post 模型可创建 ActivityFeed 模型。此外,假设 Event 模型属于某个 Calendar 模型,Photo 模型与 Tag 模型相关联,而 Post 模型属于某个 Author 模型。

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

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

自动预加载

在很多场景下,Laravel 可以自动预加载你所访问的关系。要启用自动预加载,你应在应用的 AppServiceProvider 的 boot 方法中调用 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 会自动为整个用户集合中的所有用户延迟预加载文章。同理,当你尝试访问任一已检索文章的评论时,将为最初检索到的所有文章延迟预加载全部评论。

如果你不希望全局启用自动预加载,仍可通过在集合上调用 withRelationshipAutoloading 方法,为单个 Eloquent 集合实例启用该特性:

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

return $users->withRelationshipAutoloading();

防止延迟加载

如前所述,预加载关系通常能为你的应用程序带来显著的性能收益。因此,如有需要,你可以指示 Laravel 始终防止关系的延迟加载。为此,你可以调用 Eloquent 基础模型类提供的 preventLazyLoading 方法。通常情况下,你应在应用的 AppServiceProvider 类的 boot 方法中调用此方法。

preventLazyLoading 方法接受一个可选的布尔参数,用于指示是否应防止延迟加载。例如,你可能希望仅在非生产环境中禁用延迟加载,这样即便生产代码中意外出现延迟加载的关系,你的生产环境也能继续正常运行:

use Illuminate\Database\Eloquent\Model;

/**
 * 引导任何应用服务。
 */
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

在禁止延迟加载之后,当你的应用程序尝试延迟加载任意 Eloquent 关系时,Eloquent 会抛出 Illuminate\Database\LazyLoadingViolationException 异常。

你可以使用 handleLazyLoadingViolationUsing 方法自定义延迟加载违规的处理行为。例如,借助该方法,你可以指示延迟加载违规仅记录日志,而不是通过异常中断应用程序的执行:

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

    info("Attempted to lazy load [{$relation}] on model [{$class}].");
});

插入与更新关联模型

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.']),
]);

save 与 saveMany 方法会持久化给定的模型实例,但不会将刚持久化的模型添加到父模型中已加载的任何内存关系里。如果你计划在调用 save 或 saveMany 方法后访问该关系,可以考虑使用 refresh 方法重新加载模型及其关系:

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

$post->refresh();

// All comments, including the newly saved comment...
$post->comments;

递归保存模型与关系

如果你想 save 模型及其所有关联关系,可以使用 push 方法。在此示例中,Post 模型将被保存,同时其评论以及评论的作者也会被保存:

$post = Post::find(1);

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

$post->push();

可使用 pushQuietly 方法来保存模型及其关联关系,且不会触发任何事件:

$post->pushQuietly();

create 方法

除 save 与 saveMany 方法外,你也可以使用 create 方法,该方法接收一个属性数组,创建一个模型并将其插入数据库。save 与 create 的区别在于,save 接收一个完整的 Eloquent 模型实例,而 create 接收一个普通的 PHP array。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.'],
]);

可使用 createQuietly 与 createManyQuietly 方法在不派发任何事件的情况下创建模型:

$user = User::find(1);

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

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

你还可以使用 findOrNew、firstOrNew、firstOrCreate 和 updateOrCreate 方法,在关系上创建和更新模型。

在使用 create 方法之前,请务必查阅批量赋值(mass assignment)文档。

属于(Belongs To)关系

如果你想将一个子模型分配给一个新的父模型,可以使用 associate 方法。在本示例中,User 模型定义了一个指向 Account 模型的 belongsTo 关系。该 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();

为方便起见,attach 和 detach 方法也接受 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],
]);

事务性中间表操作

上述每一种中间表操作,都有一个 OrFail 变体(attachOrFail、detachOrFail、syncOrFail、syncWithoutDetachingOrFail 和 toggleOrFail),它会在数据库事务中执行操作,从而在抛出异常时自动回滚所有更改:

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

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

更新中间表中的记录

如果你需要更新关系中间表中的现有行,可以使用 updateExistingPivot 方法。该方法接受中间表记录的外键以及一个用于更新的属性数组:

$user = User::find(1);

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

触碰父级时间戳

当一个模型定义了指向另一个模型的 belongsTo 或 belongsToMany 关系时(例如,Comment 属于 Post),在子模型更新时,更新父级的时间戳有时是很有用的。

例如,当 Comment 模型被更新时,你可能希望自动“触碰”所属 Post 的 updated_at 时间戳,将其设置为当前的日期和时间。要实现这一点,你可以在子模型上使用 Touches 特性,该特性包含需要在其 updated_at 时间戳于子模型更新时被更新的关系名称:

<?php

namespace App\Models;

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

#[Touches(['post'])]
class Comment extends Model
{
    /**
     * 获取评论所属的帖子。
     */
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}

只有当子模型通过 Eloquent 的 save 方法进行更新时,父模型的时间戳才会被更新。