## **簡介**
Eloquent 模型支持的關聯關系包括以下七種:
* 一對一
* 一對多
* 多對多
* 遠層一對多
* 多態關聯(一對一)
* 多態關聯(一對多)
* 多態關聯(多對多)
下面我們將以設計一個簡單的博客系統數據庫為例一一介紹上述關聯關系。
## **一對一**
一對一是最簡單的關聯關系,一般可用于某張數據表的擴展表與主表之間的關聯關系,比如`user`表——>`user_profiles`表。針對這樣的場景,我們就可以在兩張表對應模型之間建立一對一關聯。
我們先通過[數據庫遷移]('')創建一張`user_profiles`數據表,并創建對應模型`UserProfile`,這可以通過以下 Artisan 命令一次完成:
~~~
php artisan make:model UserProfile -m
~~~
在生成的`create_user_profiles`遷移文件中編寫遷移類的`up`方法如下:
~~~
public function up()
{
Schema::create('user_profiles', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned()->default(0)->unique();
$table->string('bio')->nullable()->comment('個性簽名');
$table->string('city')->nullable()->comment('所在城市');
$table->json('hobby')->nullable()->comment('個人愛好');
$table->timestamps();
});
}
~~~
注意,我們在`user_profiles`表中添加了一個`user_id`字段用于指向所屬用戶,從而建立于`users`表的關聯。運行`php artisan migrate`在數據庫創建這張數據表。
準備好數據表之后,接下來,我們來通過模型類建立`users`表和`user_profiles`表之間的關聯,Eloquent 模型類底層提供了相應的 API 方法幫助我們建立模型之間的關聯。首先,我們在`User`模型類中通過`hasOne`方法定義其與`UserProfile`的一對一關聯:
~~~
public function profile()
{
return $this->hasOne(UserProfile::class);
}
~~~
我們通過[數據庫填充技術]('')在`user_profiles`插入一些數據,這樣就可以在`User`模型實例上通過關聯方法名作為動態屬性訪問與其對應的`UserProfile`模型實例了:
~~~
$user = User::findOrFail(1);
$profile = $user->profile;
~~~
打印`$profile`結果如下:

### **Eloquent底層約定**
在關聯關系的建立過程中,Eloquent 也遵循了「約定大于配置」的原則。你可能注意到了我們在定義關聯關系時,僅僅指定了模型類名,并沒有指定通過哪些數據表字段建立關聯,這并不是說 Laravel 神通廣大,而是因為 Eloquent 對此做了默認的約定。`hasOne`方法的完整簽名是:
~~~
public function hasOne($related, $foreignKey = null, $localKey = null)
~~~
其中,第一個參數是關聯模型的類名,第二個參數是關聯模型類所屬表的外鍵,這里對應的是`user_profiles`表的`user_id`字段,第三個參數是關聯表的外鍵關聯到當前模型所屬表的哪個字段,這里對應的是`users`表的`id`字段。如果沒有指定`$foreignKey`,Eloquent 底層會通過如下方法去拼接:
~~~
public function getForeignKey()
{
return Str::snake(class_basename($this)).'_'.$this->getKeyName();
}
~~~
在本例中,拼接的結果正好是`user_id`。如果沒有指定`$localKey`的話,Eloquent 底層會返回主鍵 ID(本例中就是`id`):
~~~
public function getKeyName()
{
return $this->primaryKey;
}
~~~
遵循這種默認的約定,可以幫我們少寫很多代碼,減少很多額外的配置,所以如果不是迫不得已(比如從其他系統遷移過來),建議你在使用 Eloquent 的話,盡量遵循這些默認約定。如果數據表沒有遵循這種約定的話,只能手動傳參了。
### **建立相對的關聯關系**
通常我們都是通過`User`模型獲取`UserProfile`模型,但是有時候我們可能需要反過來通過`UserProfile`反查所屬的`User`模型,Eloquent 底層也為我們提供了相應的`belongsTo`方法來建立相對的一對一關聯關系,我們在`UserProfile`模型類定義其與`User`模型的關聯如下:
~~~
public function user()
{
return $this->belongsTo(User::class);
}
~~~
同樣,采用關聯關系方法名作為動態屬性即可訪問該模型所屬`User`模型實例:
~~~
$profile = UserProfile::findOrFail(2);
$user = $profile->user;
~~~
和`hasOne`方法一樣,`belongsTo`方法也是遵循了默認的約定規則,其完整方法簽名如下:
~~~
public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)
~~~
其中第一個參數是關聯模型的類名;第二個參數是當前模型類所屬表的外鍵,在本例中是`user_profiles`表的`user_id`字段,拼接規則和`hasOne`那里類似,只不過這里是基于第四個參數關聯關系名稱`$relation`;第三個參數是關聯模型類所屬表的主鍵;第四個參數`$relation`默認約定是對應關聯關系方法名,也是關聯關系動態屬性名,這里就是`user`。
## **一對多**
一對多關聯是我們日常開發中經常碰到的一種關聯關系,比如`user`表——>`post`用戶文章表。同理,我們也可以在`User`模型類中通過 Eloquent 底層提供的`hasMany`方法來實現:
~~~
public function posts()
{
return $this->hasMany(Post::class);
}
~~~
由于我們之間已經創建過`users`表和`posts`表,并且初始化過數據,所以我們可以直接通過動態屬性的方式來調用用戶模型上的文章,返回模型類集合(不同與`hasOne`返回的是單個模型實例):
~~~
$user = User::findOrFail(1);
$posts = $user->posts;
~~~
### **Eloquent底層約定**
和`hasOne`方法一樣,`hasMany`方法底層也對如何建立關聯關系做了約定,而且`hasMany`方法和`hasOne`方法的簽名一樣:
~~~
public function hasMany($related, $foreignKey = null, $localKey = null)
~~~
`$foreignKey`和`$localKey`默認獲取邏輯也和`hasOne`完全一樣,這里不再贅述。其實你完全可以把一對一關聯看作一對多關聯的簡化版本,只不過一對一退化為只返回一條記錄,所以實現邏輯一樣也不難理解了。
### **建立相對的關聯關系**
同理地,我們依然可以通過 Eloquent 提供的`belongsTo`方法在文章模型中建立于用戶模型之間的相對關聯關系,比如在文章詳細頁或列表頁顯示文章作者信息:
~~~
//為了提升代碼可讀性,關聯關系調方法名修改為`author`
public function author()
{
return $this->belongsTo(User::class, 'user_id', 'id', 'author');
}
~~~
這樣我們就可以在文章模型實例上通過動態屬性`user`來訪問對應的用戶信息:
~~~
$post = Post::findOrFail(29);
$author = $post->user;
~~~
### **渴求式加載**
前面我們提到的關聯關系查詢都是通過動態屬性的方式,這種加載方式叫做「懶惰式加載」,因為都是用到的時候才回去查詢,這就意味著多條記錄要多次對數據庫的進行查詢才能返回需要的結果,比如文章列表頁獲取作者信息(每篇文章的作者都要通過動態屬性獲取),那能不能一次就返回所有的關聯查詢結果呢?
為此,Eloquent 為我們提供了`with`方法,我們將需要查詢的關聯關系動態屬性(關聯方法名)傳入該方法,并將其鏈接到 Eloquent 模型原有的查詢中,就可以一次完成關聯查詢,加上模型自身查詢,總共查詢兩次。我們將這種加載方式叫做「渴求式加載」,即根據所需預先查詢所有數據。
以文章列表為例,我們可以通過這種方式獲取文章及對應作者信息:
~~~
$posts = Post::with('author')
->where('views', '>', 0)
->offset(1)->limit(10)
->get();
//對應的底層SQL執行語句是:
select * from `posts` where `views` > 0 and `posts`.`deleted_at` is null limit 10 offset 0;
select * from `users` where `users`.`id` in (?, ?, ?, ?, ?, ?) and `email_verified_at` is not null;
~~~
這樣就可以在返回的列表中看到關聯的作者信息了,在遍歷的時候可以通過`$post->author`獲取,而無需每次加載,從而提高數據庫查詢性能。
## **多對多**
多對多關聯比一對一和一對多關聯復雜一些,需要借助一張中間表才能建立關聯關系。以文章標簽為例,文章表已經存在了,還需要創建一張`tags`表和中間表`post_tags`。
~~~
//創建Tags模型類及其對應數據表tags遷移文件
php artisan make:model Tag -m
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->unique()->comment('標簽名');
$table->timestamps();
});
}
//創建post_tags數據表遷移文件
public function up()
{
Schema::create('post_tags', function (Blueprint $table) {
$table->increments('id');
$table->integer('post_id')->unsigned()->default(0);
$table->integer('tag_id')->unsigned()->default(0);
$table->unique(['post_id', 'tag_id']);
$table->timestamps();
});
}
~~~
接下來,我們在`Post`模型類中定義其與`Tags`模型類的關聯關系,通過 Eloquent 提供的`belongsToMany`方法來實現:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags');
}
~~~
通過數據庫填充器填充一些數據到`tags`表和`post_tags`表,這樣我們就可以通過關聯查詢查詢指定`Post`模型上的標簽信息了:
~~~
$post = Post::findOrFail(1);
$tags = $post->tags;
~~~
### **Eloquent底層約定**
我們在定義多對多關聯的時候,也沒有指定通過哪些字段進行關聯,這同樣是遵循 Eloquent 底層默認約定的功勞,`belongsToMany`方法簽名如下:
~~~
public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $relation = null)
~~~
除了第一個參數之外,其它參數都可以為空。第一個參數是關聯模型的類名,這里是`Tag`;第二個參數`$table`是建立多對多關聯的中間表名,該表名默認拼接規則如下:
~~~
$segments = [
$instance ? $instance->joiningTableSegment()
: Str::snake(class_basename($related)),
$this->joiningTableSegment(),
];
sort($segments);
return strtolower(implode('_', $segments));
~~~
其中`$this->joiningTableSegment()`將當前模型類名轉化為小寫字母+下劃線格式(注意不是復數格式,所以并不是對應默認表名),`$instance`對應關聯模型類實例,如果為空的話返回`Str::snake(class_basename($related))`,也會將關聯類名轉化為小寫字母+下劃線格式(也不是表名),然后對轉化后的字符片段按字母表排序。所以本例中如果不指定中間表名,按照默認約定該值是`post_tag`。但是為了遵循 Laravel 數據表名都是復數,這里就自定義了一回。
第三個參數是`$foreignPivotKey`指的是中間表中當前模型類的外鍵,默認拼接規則和前面一對一、一對多一樣,所以在本例中是`posts`表的`post_id`字段。
第四個參數`$relatedPivotKey`是中間表中當前關聯模型類的外鍵,拼接規則和`$foreignPivotKey`一樣,只不過作用于關聯模型類,所以在本例中是`tags`表的`tag_id`字段。
第五個參數`$parentKey`表示對應當前模型的哪個字段(即`$foreignPivotKey`映射到當前模型所屬表的哪個字段),默認是主鍵 ID,即`posts`表的`id`字段。
第六個參數`$relatedKey`表示對應關聯模型的哪個字段(即`$relatedPivotKey`映射到關聯模型所屬表的哪個字段),默認是關聯模型的主鍵 ID,即`tags`表的`id`字段。
最后一個參數`$relation`表示關聯關系名稱,用于設置查詢結果中的關聯屬性,默認是關聯方法名。
如果你沒有遵循上述約定,需要手動指定自己的參數字段,不過還是建議遵循這些默認的約定,不然寫著寫著容易把自己繞暈。
### **建立相對的關聯關系**
與之前的關聯關系一樣,多對多關聯也支持建立相對的關聯關系,而且由于多對多的雙方是平等的,不存在誰歸屬誰的問題,所以建立相對關聯的方法都是一樣的,我們可以在`Tag`模型中通過`belongsToMany`方法建立其與`Post`模型的關聯關系:
~~~
public function posts()
{
return $this->belongsToMany(Post::class, 'post_tags');
}
~~~
通過指定標簽查看歸屬該標簽下的所有文章,就可以用到類似的關聯查詢,相應的實現代碼如下:
~~~
$tag = Tag::with('posts')->where('name', 'ab')->first();
$posts = $tag->posts;
~~~
### **獲取中間表字段**
Eloquent 還提供了方法允許你獲取中間表的字段,你仔細看查詢結果字段,會發現`relations`字段中有一個`pivot`屬性,中間表字段就存放在這個屬性對象上:

我們在遍歷返回結果的時候可以在循環中通過`$post->pivot->tag_id`獲取中間表字段值。不過中間表默認只返回關聯模型的主鍵字段,如果要返回額外字段,需要在定義關聯關系的時候手動指定,比如如果想要返回時間戳信息,可以這么定義:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withTimestamps();
}
~~~
這樣就可以返回文章標簽創建時間和更新時間了:

如果除此之外,你還在中間表中定義了額外的字段信息,比如`user_id`,可以通過`with`方法傳入字段然后將其返回:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withPivot('user_id')->withTimestamps();
}
~~~
## **遠層一對多關聯**
遠層一對多在一對多關聯的基礎上加上了一個修飾詞「遠層」,意味著這個一對多關系不是直接關聯,而是「遠層」關聯,遠層怎么關聯呢?借助中間表。
舉個例子,如果我們的博客系統是針對全球市場的話,可能針對不同的國家推出不同的用戶系統和功能,然后中國用戶過來就只展示中國用戶發布的文章,日本用戶過來就只展示日本用戶發布的文章。這里面涉及到三張表,存儲國家的`countries`表,存儲用戶的`users`表,以及存儲文章的`posts`表。用戶與文章是一對多的關聯關系,國家與用戶之間是一對多的關聯(一個用戶只能有一個國籍),那么通過用戶這張中間表,國家和文章之間就建立起來「遠層」的一對多的關聯。
### **建立遠層的一對多關聯關系**
了解這個關聯的概念之后,我們要查詢某個國家下的文章,要怎么做呢?
我們先創建`Country`模型類及其對應數據庫遷移:
~~~
php artisan make:model Country -m
~~~
編寫新生成的數據庫遷移文件對應遷移類的`up`方法如下:
~~~
public function up()
{
Schema::create('countries', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->unique();
$table->string('slug', 100)->unique();
$table->timestamps();
});
}
~~~
然后,編寫遷移文件為`users`表新增一個`country_id`字段:
~~~
php artisan make:migration alter_users_add_country_id --table=users
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->integer('country_id')->unsigned()->default(0);
$table->index('country_id');
});
}
~~~
準備好數據庫、模型類并填充測試數據后,接下來,我們在`Country`模型類中通過 Eloquent 提供的`hasManyThrough`方法定義其與`Post`模型類之間的遠層一對多關聯:
~~~
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
~~~
其中,第一個參數是關聯的模型類,第二個參數是中間借助的模型類。
這樣,我們就可以在代碼中通過`Country`模型實例獲取歸屬于該國家的所有文章了,查詢方式和前面其它關聯查詢一樣,可以懶惰式加載,也可以渴求式加載:
~~~
$country = Country::findOrFail(1);
$posts = $country->posts;
~~~
### **Eloquent 底層約定**
同樣,我們在通過`hasManyThrough`方法定義遠層一對多關聯關系的時候,并沒有指定關聯字段,因為我們在定義數據庫字段、模型類的時候都遵循了 Eloquent 底層的約定。
我們來看一下`hasManyThrough`方法的完整簽名:
~~~
public function hasManyThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null)
~~~
其中,第一個參數和第二個參數分別是關聯模型類和中間模型類。
第三個參數`$firstKey`表示中間模型類與當前模型類的關聯外鍵,按照默認約定,在本例中拼接出來的字段是`country_id`,正好和我們在中間表`users`中新增的`country_id`吻合,所以不需要額外指定。
第四個參數`$secondKey`指的是中間模型類與關聯模型類的關聯外鍵,按照默認約定,在本例中拼接出來的字段是`user_id`,正好和我們在關聯表`posts`中定義的`user_id`吻合,所以也不需要額外指定。
第五個參數`$localKey`默認是當前模型類的主鍵 ID,第六個參數是中間模型類的主鍵 ID。
如果你的字段定義與 Eloquent 底層默認約定拼接出來的字段不一致,需要手動指定對應參數。
## **一對一的多態關聯**
多態關聯允許目標模型通過單個關聯歸屬于多種類型的模型,根據模型之間的關聯關系類型,又可以將多態關聯細分為一對一、一對多和多對多三種關聯。首先我們來看最簡單的一對一多態關聯。
舉個例子,在我們的博客系統中用戶可以設置頭像,文章也可以設置縮略圖,我們知道每個用戶只能有一個頭像,一篇文章也只能有一個縮略圖,所以此時用戶和圖片之間是一對一關聯,文章和圖片之間也是一對一關聯,通過多態關聯,我們可以讓用戶和文章共享與圖片的一對一關聯,我們只需要在圖片模型類通過一次定義,就可以動態建立與用戶和文章的關聯。
要建立這種多態管理,需要圖片表結構支持與對應用戶和文章的關聯,這里我們需要兩個字段建立這種關聯:1. 類型字段,表示歸屬于用戶還是文章;2. ID字段,指向對應的用戶/文章ID;So,結合這兩個字段我們就能唯一確定該圖片歸屬于哪個用戶/哪篇文章了。
首先,我們創建圖片模型類`Image`及其對應數據庫遷移文件:
~~~
php artisan make:model Image -m
public function up()
{
Schema::create('images', function (Blueprint $table) {
$table->increments('id');
$table->string('url')->comment('圖片URL');
$table->morphs('imageable');
$table->timestamps();
});
}
~~~
其中`$table->morphs('imageable')`用于創建`imageable_id`和`imageable_type`兩個字段,其中`imageable_type`用于存放`User`模型類或`Post`模型類,而`imageable_id`用于存放對應的模型實例 ID,從而方便遵循默認約定建立多態關聯。
然后,我們在模型類中建立一對一的多態關聯:在`Image`模型類中通過`morphTo`建立其于`User/Post`模型類之間的關聯:
~~~
public function imageable()
{
return $this->morphTo();
}
~~~
我們不需要指定任何字段,因為我們在創建數據表和定義關聯方法的時候都遵循了 Eloquent 底層的約定,還是來看下`morphTo`方法的完整簽名:
~~~
public function morphTo($name = null, $type = null, $id = null, $ownerKey =null )
~~~
第一個參數`$name`是關聯關系名稱,默認就是關聯方法名,在本例中是`imageable`。
第二個參數`$type`、第三個參數`$id`結合第一個參數`$name`用于構建關聯字段,在本例中就是`imageable_type`和`imageable_id`。由于我們的數據庫字段和關聯方法名都遵循了默認約定,所以不需要額外指定。如果你的數據庫字段名是自定義的,比如`item_id`和`item_type`,那么就需要指定第一個參數值為`item`。
最后一個參數是當前模型類的主鍵 ID。
這樣,我們就可以在`images`表中填充一些測試數據進行測試了,你可以借助填充器來填充,或者手動插入,需要注意的是在`imageable_type`字段中需要插入完整的類名作為類型,比如`App\User`或者`App\Post`,以便 Eloquent 在插詢的時候結合`imageable_id`字段利用反射構造對應的模型實例:

這樣,我們就可以在`Image`實例上獲取其歸屬的模型實例了:
~~~
$image = Image::findOrFail(1);
$item = $image->imageable;
~~~
### **定義相對的關聯關系**
我們在日常開發中,更常見的是獲取某個用戶的頭像或者某篇文章的縮略圖,這樣我們就需要在`User`模型中定義其與`Image`模型的關聯:
~~~
public function image()
{
return $this->morphOne(Image::class, 'imageable');
}
//Post模型中完全雷同
~~~
同樣,因為我們遵循了 Eloquent 底冊的約定,只需要傳入最少的參數即可建立關聯。`morphOne`方法的完整簽名如下:
~~~
public function morphOne($related, $name, $type = null, $id = null, $localKey = null)
~~~
第一個參數表示關聯的模型類。
第二個參數`$name`、第三個參數`$type`、第四個參數`$id`和前面的`morphTo`方法的前三個參數一樣,用于在關聯表中拼接關聯外鍵,在本例中就是`imageable_type`和`imageable_id`,所以第三個和第四個參數不需要額外指定,當然如果你是用的是`item_id`和`item_type`字段需要將第二個參數設置為`item`,如果結尾不是以`type`和`id`作為后綴,也需要通過`$type`和`$id`參數傳入。
最后一個參數`$localKey`表示當前模型類的主鍵 ID。
在模型類中定義完關聯方法后,就可以在代碼中通過相應方法獲取關聯模型了:
~~~
$post = Post::findOrFail(1);
$image = $post->image;
//對應的查詢SQL語句為:
select * from `images`
where `images`.`imageable_id` = 1 and `images`.`imageable_id` is not null and `images`.`imageable_type` = "App\Post"
limit 1
~~~
## **一對多的多態關聯**
理解了一對一的多態關聯之后,一對多的多態關聯理解起來就簡單多了,其實就是模型類與關聯類之間的關聯變成一對多了,只不過這個一對多是多態的。
其實所謂多態,就是在關聯表引入了類型的概念,關聯表中的數據不再是與某一張表有關聯,而是與多張表有關聯,具體是哪張表通過關聯類型來確定,具體與哪條記錄關聯,通過關聯ID來確定。
舉個例子,以Laravel學院為例,它支持兩種類型的內容發布,一種是普通的文章,一種是獨立的頁面,分別存在兩張表里。博客系統中免不了評論系統,用戶可以評論普通文章,也可以評論頁面,留言內容都放在評論表(結構完全一樣)。如果單獨看文章和評論,它們是一對多的關系,現在我們的評論表還要支持頁面評論的存儲,因此,需要引入一個類型字段做區分,這樣文章/頁面與評論之間的關聯關系就變成一對多的多態關聯了。
首先,我們還是創建評論模型類`Comment`及其數據庫遷移文件:
~~~
php artisan make:model Comment -m
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->increments('id');
$table->text('content')->comment('評論內容');
$table->integer('user_id')->unsigned()->default(0);
$table->morphs('commentable');
$table->index('user_id');
$table->softDeletes();
$table->timestamps();
});
}
~~~
然后在`Comment`模型類中通過 Eloquent 提供的`morphTo`方法定義其與`Post`模型和`Page`之間的一對多多態關聯:
~~~
public function commentable()
{
return $this->morphTo();
}
~~~
因為一個評論只會對應一篇文章/頁面,所以,通過和一對一的多態關聯同樣的`morphTo`方法定義其與文章和頁面的關聯關系即可。同時,因為我們的數據表字段和關聯方法名都遵循了 Eloquent 底層的默認約定,所以不需要指定任何額外參數,即可完成關聯關系的構建。這些默認約定我們在上面一對一多態關聯中已經詳細列出,這里就不再贅述了。
這樣,我們就可以通過`Comment`實例查詢其歸屬的文章或頁面了:
~~~
$comment = Comment::findOrFail(1);
$item = $comment->commentable;
~~~
### **定義相對的關聯關系**
同樣我們在日常開發中,更多的是通過文章或頁面實例獲取對應的評論信息,比如在文章頁或頁面頁獲取該文章或頁面的所有評論。為此,我們需要在`Post`模型類和`Page`模型類中定義其與`Comment`模型的關聯關系,這需要通過`morphMany`方法來實現:
~~~
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
~~~
和`morphOne`方法一樣,因為我們遵循了 Eloquent 底層的默認約定,所以只需要傳遞很少的必要參數就可以定義關聯關系了,`morphMany`方法的完整簽名如下:
~~~
public function morphMany($related, $name, $type = null, $id = null, $localKey = null)
~~~
這些參數的含義和`morphOne`方法完全一樣,這里就不再贅述了。如果想要在`Post`模型下獲取對應的所有評論,可以這么做:
~~~
$post = Post::with('comments')->findOrFail(23);
$comments = $post->comments;
//對應的查詢SQL語句為:
select * from `comments`
where `comments`.`commentable_id` in (23) and `comments`.`commentable_type` = "App\Post" and `comments`.`deleted_at` is null
~~~
## **多對多的多態關聯**
多對多的多態關聯比前面的一對一和一對多更加復雜,但是有了前面講解的基礎,理解起來也很簡單。你可以類比下常規的多對多關聯,現在加入了「多態」的概念,常規的多對多需要借助中間表,多態的也是,只不過此時不僅僅是兩張表之間的關聯,而是也要引入類型字段。
舉個例子,以文章和標簽的關聯為例,在常規的多對多關聯中,中間表只需要一個標簽 ID 和文章 ID 即可建立它們之間的關聯,但當我們添加新的內容類型,比如頁面、視頻、音頻,它們也有標簽,而且完全可以共享一張標簽表,此時僅僅一個文章 ID 已經滿足不了定義內容與標簽之間的關聯了,所以此時引入多對多的多態關聯,和前面兩種多態關聯思路一樣,只是在多對多關聯中,我們需要在中間表中引入類型字段來標識內容類型,將原來的文章ID調整為內容ID,這樣就可以從數據庫層面滿足不同內容類型與標簽之間的關聯了。
首先我們要廢棄原來的`post_tags`數據表,創建一個新的`taggables`數據表來構建不同內容類型與標簽之間的關聯:
~~~
php artisan make:migration create_taggables_table --create=taggables
Schema::create('taggables', function (Blueprint $table) {
$table->increments('id');
$table->integer('tag_id');
$table->morphs('taggable');
$table->index('tag_id');
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
$table->timestamps();
});
~~~
然后,在`Tag`模型類中通過Eloquent提供的`morphedByMany`方法定義其與其他模型類的多對多多態關聯:
~~~
public function posts()
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function pages()
{
return $this->morphedByMany(Page::class, 'taggable');
}
~~~
和之前一樣,因為我們遵循了 Eloquent 底層的默認約定,所以我們只需傳遞必需參數,無需額外配置即可定義關聯關系,我們來看下`morphedByMany`方法的完整簽名:
~~~
public function morphedByMany($related, $name, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null)
~~~
其中第一個參數`$related`表示關聯的模型類。
第二個參數`$name`表示關聯的名稱,和定義中間表數據庫遷移的時候`morphs`方法中指定的值一致,也就是`taggable`。
第三個參數`$table`表示中間表名稱,默認是第二個參數`$name`的復數格式,這里就是`taggables`了,因為我們在創建數據表的時候遵循了這一約定,所以不需要額外指定。
第四個參數`$foreignPivotKey`表示當前模型類在中間表中的外鍵,默認拼接結果是`tag_id`,和我們在數據表中定義的一樣,所以這里不需要額外指定。
第五個參數`$relatedPivotKey`表示默認是通過`$name`和`_id`組合而來,表示中間表中的關聯ID字段,這里組合結果是`taggable_id`,和我們定義的一致,也不需要額外指定。
第六個參數`$parentKey`默認表示當前模型類的主鍵 ID,即與中間表中`tag_id`關聯的字段。
第七個參數`$relatedKey`表示關聯模型類的主鍵 ID,這個因`$related`指定的模型而定。
如果你不是按照默認約定的規則定義的數據庫字段,需要明確每一個參數的含義,然后傳入對應的參數值,和之前一樣,對新手來說,還是按照默認約定來比較好,免得出錯。
定義好上述關聯關系后,就可以查詢指定標簽模型上關聯的文章/頁面了:
~~~
$tag = Tag::with('posts', 'pages')->findOrFail(53);
$posts = $tag->posts;
$pages = $tag->pages;
~~~
### **定義相對的關聯關系**
我們還可以在`Post`模型類或`Page`模型類中通過 Eloquent 提供的`morphToMany`方法定義該模型與`Tag`模型的關聯關系(兩個模型類中定義的方法完全一樣):
~~~
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
~~~
因為我們遵循和 Eloquent 底層默認的約定,所以指定很少的參數就可以定義多對多的多態關聯,`morphToMany`方法的完整簽名如下:
~~~
public function morphToMany($related, $name, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $inverse = false)
~~~
其中前七個參數和`morphedByMany`方法含義一致,只不過針對的關聯模型對調過來,最后一個參數`$inverse`表示定義的是否是相對的關聯關系,默認是`false`。如果你是不按套路出牌自定義的字段,需要搞清楚以上參數的含義并傳入自定義的參數值。
定義好上述關聯關系后,就可以通過`Post`模型或`Page`模型獲取對應的標簽信息了:
~~~
$post = Post::with('tags')->findOrFail(6);
$tags = $post->tags;
//對應的查詢SQL語句為:
select `tags`.*, `taggables`.`taggable_id` as `pivot_taggable_id`,
`taggables`.`tag_id` as `pivot_tag_id`,
`taggables`.`taggable_type` as `pivot_taggable_type`
from `tags`
inner join `taggables` on `tags`.`id` = `taggables`.`tag_id`
where `taggables`.`taggable_id` in (6) and `taggables`.`taggable_type` = "App\Post"
~~~
## **基于關聯查詢過濾模型實例**
有的時候,可能需要根據關聯查詢的結果來過濾查詢結果,比如我們想要獲取所有發布過文章的用戶,可以這么做:
~~~
$users = User::has('posts')->get();
//對應的EXISTS查詢為:
select * from `users`
where exists (
select * from `posts`
where `users`.`id` = `posts`.`user_id` and `posts`.`deleted_at` is null
) and `email_verified_at` is not null
~~~
如果你想要進一步過濾發布文章數量大于 1 的用戶,可以帶上查詢條件:
~~~
$users = User::has('posts', '>', 1)->get();
~~~
你甚至還可以通過嵌套關聯查詢的方式過濾發布的文章有評論的用戶:
~~~
$users = User::has('posts.comments')->get();
~~~
此外,還有一個`orHas`方法,顧名思義,它會執行一個`OR`查詢,比如我們想要過濾包含評論或標簽的文章:
~~~
$posts = Post::has('comments')->orHas('tags')->get();
~~~
如果你想要通過更復雜的關聯查詢過濾模型實例,還可以通過`whereHas`/`orWhereHas`方法基于閉包函數定義查詢條件,比如我們想要過濾發布文章標題中包含「Laravel學院」的所有用戶:
~~~
$users = User::whereHas('posts', function ($query) {
$query->where('title', 'like', 'Laravel學院%');
})->get();
//對應的SQL語句為:
select * from `users`
where exists (
select * from `posts`
where `users`.`id` = `posts`.`user_id` and `posts`.`deleted_at` is null
and `title` like "Laravel學院%"
) and `email_verified_at` is not null
~~~
如果你想進一步過濾出文章標題和評論都包含「Laravel學院」的用戶,可以在上述閉包函數中通過查詢構建器進一步指定:
~~~
$users = User::whereHas('posts', function ($query) {
$query->where('title', 'like', 'Laravel學院%')
->whereExists(function ($query) {
$query->from('comments')
->whereRaw('`posts`.`id` = `comments`.`commentable_id`')
->where('content', 'like', 'Laravel學院%')
->where('commentable_type', Post::class)
->whereNull('deleted_at');
});
})->get();
~~~
與`has`/`orHas/whereHas/orWhereHas`方法相對的,還有一對`doesntHave`/`orDoesntHave/whereDoesntHave/orWhereDoesntHave`方法。很顯然,它們用于過濾不包含對應關聯結果的模型實例,使用方法一樣,不再贅述。
### **統計關聯模型**
我們還可以通過 Eloquent 提供的`withCount`方法在不加載關聯模型的情況下統計關聯結果的數量。比如我們想要統計某篇文章的評論數,可以這么做:
~~~
$post = Post::withCount('comments')->findOrFail(32);
~~~

返回結果中包含了`comments_count`字段,通過這個字段就可以訪問該文章的評論數。如果要統計其它關聯模型結果數量字段,可以依次類推,對應字段都是`{relation}_count`結構。
> 注:實際開發中為了提高查詢性能,我們往往是在`posts`表中冗余提供一個`comments_count`字段,每新增一條評論,該字段值加 1,查詢的時候直接取該字段即可,從而提高查詢的性能。
此外,你還可以通過數組傳遞多個關聯關系一次統計多個字段,還可以通過閉包函數指定對應統計的過濾條件:
~~~
$post = Post::withCount(['tags', 'comments' => function ($query) {
$query->where('content', 'like', 'Laravel學院')
->orderBy('created_at', 'desc');
}])->findOrFail(32);
~~~
### **渴求式加載**
我們在前面已經介紹過,渴求式加載通過`with`方法實現:
~~~
$post = Post::with('author')->findOrFail(1);
$author = $post->author;
~~~
渴求式加載會在查詢到模型實例結果后,通過`IN`查詢獲取關聯結果,并將其附著到對應的模型實例上,在后面訪問的時候不會再對數據庫進行查詢。所以不管模型實例有多少個,關聯結果只會查詢一次,加上模型本身查詢總共是兩次查詢,在列表查詢時,大大減少了對數據庫的連接查詢次數,因而有更好的性能表現,推薦使用。
渴求式加載支持一次加載多個關聯模型(參數名對應相應的關聯方法名):
~~~
$posts = Post::with('author', 'comments', 'tags')->findOrFail(1);
~~~
返回的數據格式如下:

此外,渴求式加載還支持嵌套查詢,比如我們想要訪問文章作者的擴展表信息,可以這么做:
~~~
$post = Post::with('author.profile')->findOrFail(1);
~~~
有時候,你可能覺得一次性加載所有關聯數據有點浪費,對于特定條件下才使用的數據我們可以通過動態條件判斷進行渴求式加載或者延遲加載。我們將這種加載叫做懶惰渴求式加載,這種加載可以通過`load`方法實現:
~~~
$users = User::all();
$condition = true;
if ($condition) {
$users->load('posts');
}
~~~
懶惰渴求式加載也是渴求式加載,只不過是在需要的時候才去加載,所以加上了「懶惰」這個修飾詞,底層執行的 SQL 查詢語句和渴求式加載是一樣的。
## **關聯插入與更新**
### **一對多關聯記錄插入**
新增關聯模型的時候,可以在父模型上調用相應方法直接插入記錄到數據庫,這樣做的好處是不需要指定關聯模型與父模型的外鍵關聯字段值,Eloquent 底層會自動判斷并設置。比如,如果我們要在某篇文章上新增一條評論可以這么做:
~~~
$post = Post::findOrFail(1);
$faker = \Faker\Factory::create();
$comment = new Comment();
$comment->content = $faker->paragraph;
$comment->user_id = mt_rand(1, 15);
$post->comments()->save($comment);
~~~
Eloquent 底層會自動幫我們維護`commentable_id`和`commentable_type`字段。
還可以通過`saveMany`方法一次插入多條關聯記錄,前提是為關聯模型配置了[批量賦值](''),比如我們為`Comment`模型類配置白名單`$fillable`屬性如下(你也可以不配置批量賦值,但是需要多次實例化并逐個設置評論模型屬性值,很麻煩):
~~~
protected $fillable = [
'content', 'user_id'
];
~~~
這樣我們就可以批量插入文章評論數據了:
~~~
$post = Post::findOrFail(1);
$faker = \Faker\Factory::create();
$post->comments()->saveMany([
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)])
]);
~~~
此外,我們還可以通過`create`/`createMany`方法來插入關聯數據,與`save`/`saveMany`方法不同的是,這兩個方法接收的是數組參數:
~~~
// 插入一條記錄
$post->comments()->create([
'content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)
]);
// 插入多條記錄
$post->comments()->createMany([
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)],
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)],
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]
]);
~~~
### **更新一對多所屬模型外鍵字段**
如果是要更新新創建的模型實例所屬模型(父模型)的外鍵字段,比如以`posts`表為例,新增的記錄想要更新`user_id`字段,可以這么實現:
~~~
$user = User::findOrFail(1);
$post->author()->associate($user);
$post->save();
~~~
相對的,如果想要解除當前模型與所屬模型之間的關聯,可以通過`dissociate`方法來實現:
~~~
$post->author()->dissociate();
$post->save();
~~~
這樣,就會將`posts.user_id`置為`null`。前提是`user_id`允許為`null`,否則會拋出異常。
### **多對多關聯的綁定與解除**
在插入多對多關聯記錄的時候,可以通過上面一對多關聯記錄插入的方式。以文章與標簽為例,完全可以這樣通過文章模型新增標簽模型,同時更新中間表記錄:
~~~
// 插入單條記錄
$post->tags()->save(
new Tag(['name' => $faker->word])
);
// 如果中間表接收額外參數可以通過第二個參數傳入
$post->tags()->save(
new Tag(['name' => $faker->word]),
['user_id' => 1]
);
// 插入多條記錄
$post->tags()->saveMany([
new Tag(['name' => $faker->unique()->word]),
new Tag(['name' => $faker->unique()->word]),
new Tag(['name' => $faker->unique()->word])
]);
// 如果插入多條記錄需要傳遞中間表額外字段值(通過鍵值關聯對應記錄與額外字段)
$post->tags()->saveMany([
1 => new Tag(['name' => $faker->unique()->word]),
2 => new Tag(['name' => $faker->unique()->word]),
3 => new Tag(['name' => $faker->unique()->word])
], [
1 => ['user_id' => 1],
2 => ['user_id' => 2],
3 => ['user_id' => 3],
]);
~~~
此外,Eloquent 底層還提供了為已有模型之間進行多對多關聯的綁定和解除操作。還是以文章和標簽為例,要將兩個本來沒有關聯關系的記錄綁定起來,可以通過`attach`方法實現:
~~~
$post = Post::findOrFail(1);
$tag = Tag::findOrFail(1);
$post->tags()->attach($tag->id);
// 如果中間表還有其它額外字段,可以通過第二個數組參數傳入
// $post->tags()->attach($tag->id, ['user_id' => $userId]);
// 還可以一次綁定多個標簽
// $post->tags()->attach([1, 2]);
// 如果綁定多個標簽,要傳遞額外字段值,可以這么做:
/*$post->tags()->attach([
1 => ['user_id' => 1],
2 => ['user_id' => 2]
]);*/
~~~
如果要解除這個關聯關系可以通過`detach`方法實現:
~~~
$post->tags()->detach(1);
// 如果想要一次解除多個關聯,可以這么做:
// $post->tags()->detach([1, 2]);
// 如果想要一次解除所有關聯,可以這么做:
// $post->tags()->detach();
~~~
上面這兩種方法很方便,但還有更方便的,當我們在更新某篇文章的標簽時,往往同時涉及關聯標簽的綁定和解除。按照上面的邏輯,我們需要先把所有標簽記錄查詢出來,再判斷哪些需要綁定關聯、哪些需要解除關聯、哪些需要插入新的標簽記錄,然后再通過`attach`和`detach`方法最終完成與對應文章的綁定和解除關聯。
對于那些已存在的標簽記錄,我們可以通過更高效的方法與文章進行關聯關系的綁定和解除,這個方法就是`sync`,調用該方法時只需傳入剛創建/更新后文章的標簽對應 ID 值,至于哪些之前不存在的關聯需要綁定,哪些存在的關聯需要解除,哪些需要維護現狀,交由 Eloquent 底層去判斷:
~~~
$post->tags()->sync([1, 2, 3]);
~~~
如果對應新增數據需要傳遞額外參數,參考`attach`即可,兩者是一樣的。
有時候,你可能僅僅是想要更新中間表字段值,這個時候,可以通過`updateExistingPivot`方法在第二個參數中將需要更新的字段值以關聯數組的方式傳遞過去:
~~~
$post->tags()->updateExistingPivot($tagId, $attributes);
~~~
### **觸發父模型時間戳更新**
當一個模型歸屬于另外一個模型時,例如`Comment`模型歸屬于`Post`模型,當子模型更新時,父模型的更新時間也同步更新往往很有用,比如在有新評論時觸發文章頁緩存更新,或者通知搜索引擎頁面有更新等等。Eloquent 提供了這種同步機制幫助我們更新子模型時觸發父模型的更新時間`updated_at`字段值更新,要讓該機制生效,需要在子模型中配置`$touches`屬性:
~~~
// 要觸發更新的父級關聯關系
protected $touches = [
'commentable'
];
~~~
屬性值是對應關聯方法的名稱,支持配置多個關聯關系。