본문으로 건너뛰기
버전: 8.x

Eloquent: 연관관계 (Eloquent: Relationships)

소개

데이터베이스 테이블은 서로 연관되어 있는 경우가 많습니다. 예를 들어, 하나의 블로그 게시물에는 여러 개의 댓글이 달릴 수 있고, 주문 정보는 주문을 생성한 사용자와 연결될 수 있습니다. Eloquent를 사용하면 이러한 연관관계를 아주 쉽고 편리하게 다룰 수 있으며, 아래와 같은 다양한 일반적인 연관관계를 지원합니다.

연관관계 정의하기

Eloquent에서 연관관계는 Eloquent 모델 클래스의 메서드로 정의합니다. 연관관계 또한 강력한 쿼리 빌더의 역할을 하므로, 메서드로 정의하면 메서드 체이닝과 편리한 쿼리 작성이 가능합니다. 예를 들어, 아래와 같이 posts 연관관계에 추가로 쿼리 조건을 연결할 수 있습니다.

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

하지만 본격적으로 연관관계를 활용하기 전에, Eloquent에서 지원하는 각 연관관계 유형을 어떻게 정의하는지부터 살펴보겠습니다.

일대일(One To One)

일대일 연관관계는 가장 기본적인 데이터베이스 연관관계입니다. 예를 들어, User 모델이 Phone 모델 하나와 연결되어 있을 수 있습니다. 이 연관관계를 정의하려면, User 모델에 phone이라는 메서드를 추가해서, 해당 메서드에서 hasOne 메서드를 호출한 결과값을 반환하면 됩니다. hasOne 메서드는 모델의 기본 클래스인 Illuminate\Database\Eloquent\Model을 통해 제공됩니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
/**
* 사용자와 연결된 전화번호를 가져옵니다.
*/
public function phone()
{
return $this->hasOne(Phone::class);
}
}

hasOne 메서드의 첫 번째 인자는 연관된 모델의 클래스명을 전달합니다. 연관관계를 정의하면, Eloquent의 동적 속성을 사용하여 관련된 레코드를 조회할 수 있습니다. 동적 속성을 사용하면 마치 모델에 정의된 일반 속성처럼 연관관계 메서드에 접근할 수 있습니다.

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

Eloquent는 부모 모델명을 기준으로 연관된 테이블의 외래 키(foreign key)명을 자동으로 결정합니다. 위 예시에서는 Phone 모델이 기본적으로 user_id 외래키를 가진 것으로 간주합니다. 만약 이 규칙을 변경하고 싶다면, hasOne 메서드의 두 번째 인자로 원하는 외래 키명을 전달하면 됩니다.

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

또한 Eloquent는 외래 키에 저장된 값이 부모 모델의 기본키(primary key) 컬럼 값과 일치한다고 가정합니다. 즉, Eloquent는 Phone 레코드의 user_id 컬럼에서 사용자의 id 컬럼 값을 찾아줍니다. 만약 id가 아닌 다른 컬럼을 기본키로 사용하고 싶거나, 모델의 $primaryKey 속성 외의 값을 사용하고 싶다면, 세 번째 인자로 로컬 키(local key)를 명시하면 됩니다.

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

연관관계의 역방향 정의하기

이제 User 모델에서 Phone 모델을 참조할 수 있습니다. 이번에는 Phone 모델에서 해당 폰의 주인인 사용자를 참조할 수 있도록 연관관계를 정의해보겠습니다. hasOne의 반대 관계인 역방향 연관관계는 belongsTo 메서드를 사용하여 정의합니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Phone extends Model
{
/**
* 이 전화번호의 소유자인 사용자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

user 메서드를 호출하면, Eloquent는 Phone 모델의 user_id 컬럼 값과 일치하는 id를 가진 User 모델을 찾아 연결해줍니다.

Eloquent는 연관관계 메서드명을 분석하여 외래 키명을 정합니다. 일반적으로 메서드명에 _id를 붙여서 외래키 컬럼을 예상합니다. 즉, 위 예시에서는 Eloquent가 Phone 모델에 user_id 컬럼이 있다고 간주합니다. 만약 외래키가 user_id가 아니라면, 두 번째 인자로 외래 키명을 직접 지정할 수 있습니다.

/**
* 이 전화번호의 소유자인 사용자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class, 'foreign_key');
}

부모 모델이 id 이외의 컬럼을 기본키로 사용하거나, 연관 모델을 다른 컬럼 기준으로 찾고 싶을 때는, 세 번째 인자로 부모 테이블의 기본키 컬럼명을 지정할 수 있습니다.

/**
* 이 전화번호의 소유자인 사용자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class, 'foreign_key', 'owner_key');
}

일대다(One To Many)

일대다 연관관계는 하나의 모델(부모)이 여러 하위 모델(자식)과 관계맺을 때 사용합니다. 예를 들어, 하나의 게시글에는 무한정 많은 댓글이 달릴 수 있습니다. 다른 Eloquent 연관관계와 마찬가지로, 일대다 관계도 모델에 메서드를 정의하는 방식으로 만들 수 있습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
/**
* 이 게시글에 달린 댓글들을 반환합니다.
*/
public function comments()
{
return $this->hasMany(Comment::class);
}
}

Eloquent는 Comment 모델의 외래키 컬럼명을 자동으로 결정합니다. 기본적으로, 부모 모델 이름을 스네이크 케이스(snake case) 변환 후 _id를 붙인 컬럼이 외래키로 사용됩니다. 이 예시라면 Comment 모델의 외래키 컬럼은 post_id가 됩니다.

연관관계 메서드를 정의한 후에는, 컬렉션 형태로 관련 댓글들을 쉽게 조회할 수 있습니다. Eloquent의 "동적 연관 속성" 덕분에, 마치 속성처럼 연관관계 메서드에 접근할 수 있습니다.

use App\Models\Post;

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

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

모든 연관관계는 쿼리 빌더 역할을 함께 하므로, 메서드 체이닝을 통해 추가 조건을 연결한 쿼리도 작성할 수 있습니다.

$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');

일대다(역방향) / Belongs To

이제 게시글의 댓글을 모두 조회할 수 있게 되었으니, 댓글에서 상위 게시글(부모)을 참조하는 연관관계도 만들어보겠습니다. hasMany의 반대로, 자식 모델에서 부모 모델을 바라보게 하려면 belongsTo 메서드를 이용해 연관관계 메서드를 정의하면 됩니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
/**
* 이 댓글이 달린 게시글을 반환합니다.
*/
public function post()
{
return $this->belongsTo(Post::class);
}
}

이제 연관관계가 정의되었으니, 댓글 인스턴스에서 부모 게시글을 다음과 같이 동적 속성(dynamc property)으로 접근할 수 있습니다.

use App\Models\Comment;

$comment = Comment::find(1);

return $comment->post->title;

위 예시에서 Eloquent는 Comment 모델의 post_id 컬럼 값과 일치하는 id를 가진 Post 모델을 찾아 연결합니다.

Eloquent는 연관관계 메서드명을 기준으로, _와 부모 모델의 기본키 컬럼명을 조합해 외래키 컬럼명을 정합니다. 이 예시에서는 comments 테이블의 외래키가 post_id로 간주됩니다.

하지만, 연관관계의 외래키 이름이 이 규칙을 따르지 않는 경우라면 belongsTo 메서드의 두 번째 인자로 직접 외래키 이름을 지정할 수 있습니다.

/**
* 이 댓글이 달린 게시글을 반환합니다.
*/
public function post()
{
return $this->belongsTo(Post::class, 'foreign_key');
}

마찬가지로 부모 모델이 id가 아닌 다른 컬럼을 기본키로 사용하거나, 연관 모델을 다른 기준 컬럼으로 찾고 싶다면 세 번째 인자로 지정할 수 있습니다.

/**
* 이 댓글이 달린 게시글을 반환합니다.
*/
public function post()
{
return $this->belongsTo(Post::class, 'foreign_key', 'owner_key');
}

기본(Default) 모델 반환

belongsTo, hasOne, hasOneThrough, morphOne 연관관계에서는 해당 관계가 null일 때 반환할 기본 모델을 정의할 수 있습니다. 이 패턴은 흔히 널 객체 패턴(Null Object pattern)이라 불리며, 코드에서 조건문 검사를 줄이는 데 도움이 됩니다. 아래 예시에서는 Post 모델이 user 모델과 연결되어 있지 않더라도, 빈 App\Models\User 모델이 반환됩니다.

/**
* 게시글의 작성자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class)->withDefault();
}

기본 모델을 특정 속성값으로 채우고 싶을 때는, withDefault 메서드에 배열이나 클로저를 전달할 수 있습니다.

/**
* 게시글의 작성자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class)->withDefault([
'name' => 'Guest Author',
]);
}

/**
* 게시글의 작성자를 반환합니다.
*/
public function user()
{
return $this->belongsTo(User::class)->withDefault(function ($user, $post) {
$user->name = 'Guest Author';
});
}

Belongs To 연관관계 쿼리하기

"Belongs To" 관계의 하위 모델을 쿼리할 때, 조건을 직접 지정해서 Eloquent 모델을 조회할 수 있습니다.

use App\Models\Post;

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

하지만 이보다 더 편리하게, whereBelongsTo 메서드를 사용하면 모델과 연관된 관계 및 외래키를 자동으로 판단하여 쿼리를 만들어줍니다.

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

기본적으로 Laravel은 전달된 모델의 클래스명을 기준으로 연관관계를 찾아줍니다. 하지만, 연관관계명을 직접 지정할 수도 있으며, 이 경우 두 번째 인자로 명칭을 넘기면 됩니다.

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

Has One Of Many

어떤 모델이 여러 연관 모델을 가질 때, 그 중에서 "가장 최근" 혹은 "가장 오래된" 한 개의 연관 모델을 편리하게 가져오고 싶을 때가 있습니다. 예를 들어, User 모델은 여러 개의 Order와 관계가 있지만, 가장 최근 주문만 간편하게 조회하고 싶을 수 있습니다. 이럴 때는 hasOneofMany 메서드를 조합해서 사용하면 됩니다.

/**
* 사용자의 가장 최근 주문을 반환합니다.
*/
public function latestOrder()
{
return $this->hasOne(Order::class)->latestOfMany();
}

마찬가지로, "가장 오래된" 즉, 가장 먼저 생성된 연관 모델도 아래와 같이 가져올 수 있습니다.

/**
* 사용자의 가장 오래된 주문을 반환합니다.
*/
public function oldestOrder()
{
return $this->hasOne(Order::class)->oldestOfMany();
}

기본적으로 latestOfManyoldestOfMany는 모델의 기본키(primary key)를 오름차순 또는 내림차순으로 정렬해 가장 최근 혹은 가장 오래된 레코드를 반환합니다. (기본키는 정렬이 가능한 값이어야 합니다.) 하지만 때로는 다른 컬럼을 기준으로 특정 모델을 선택해야 할 수도 있습니다.

예를 들어, ofMany 메서드를 활용해 사용자의 가장 비싼 주문을 조회할 수도 있습니다. ofMany의 첫 번째 인자로 정렬에 사용할 컬럼명을, 두 번째 인자로 적용할 집계 함수(min 또는 max)를 지정합니다.

/**
* 사용자의 가장 비싼 주문을 반환합니다.
*/
public function largestOrder()
{
return $this->hasOne(Order::class)->ofMany('price', 'max');
}

[!NOTE] PostgreSQL은 UUID 컬럼에 대해 MAX 함수를 지원하지 않으므로, PostgreSQL UUID 컬럼과 one-of-many 관계를 조합해서는 사용할 수 없습니다.

고급 Has One Of Many 연관관계

더 복잡한 "has one of many" 연관관계도 만들 수 있습니다. 예를 들어, Product 모델이 여러 개의 Price 모델과 관계를 맺고 있고, 새로운 가격 정보가 미리 등록되어 미래의 특정 시점부터 적용될 수 있다고 해보겠습니다. 이때는 published_at 컬럼을 활용해 미래가 아닌, 이미 퍼블리싱된 최신 가격만 조회해야 합니다. 또한, 같은 퍼블리시 날짜라면 id값이 가장 큰 가격을 우선시한다고 가정합시다.

이렇게 여러 기준을 활용하려면, ofMany에 정렬 컬럼과 집계 함수를 배열로 전달하고, 추가 조건은 두 번째 인자에 클로저로 담아 정의하면 됩니다.

/**
* 이 상품의 현재 가격 정보를 반환합니다.
*/
public function currentPricing()
{
return $this->hasOne(Price::class)->ofMany([
'published_at' => 'max',
'id' => 'max',
], function ($query) {
$query->where('published_at', '<', now());
});
}

Has One Through

"has-one-through" 관계는 최종적으로 한 개의 다른 모델과 일대일 관계를 맺지만, 그 사이에 중간 모델을 한 번 거쳐야 할 때 사용합니다.

예를 들어, 자동차 수리소 애플리케이션에서 Mechanic 모델과 Car 모델이 1:1 관계이고, CarOwner 모델도 1:1 관계라고 해봅시다. 이 경우 정비공과 차의 소유주는 DB상 직접적인 관계가 없지만, 정비공은 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;

class Mechanic extends Model
{
/**
* 정비하는 자동차의 소유주를 반환합니다.
*/
public function carOwner()
{
return $this->hasOneThrough(Owner::class, Car::class);
}
}

hasOneThrough의 첫 번째 인자는 최종적으로 접근할 모델, 두 번째 인자는 중간에 거치는 모델의 클래스명을 전달합니다.

키 네이밍 규칙

기본적으로 Eloquent는 외래키 명명 규칙을 활용해 쿼리를 작성합니다. 만약 관계에 사용할 키를 직접 커스터마이징하고 싶다면, hasOneThrough 메서드의 세 번째와 네 번째 인자로 키명을 넘기면 됩니다. 세 번째 인자는 중간 모델(예: cars)의 외래키, 네 번째 인자는 최종 모델(owners)의 외래키, 다섯 번째 인자는 mechanics 테이블의 로컬키, 여섯 번째 인자는 cars 테이블의 로컬키입니다.

class Mechanic extends Model
{
/**
* 정비하는 자동차의 소유주를 반환합니다.
*/
public function carOwner()
{
return $this->hasOneThrough(
Owner::class,
Car::class,
'mechanic_id', // cars 테이블의 외래키...
'car_id', // owners 테이블의 외래키...
'id', // mechanics 테이블의 로컬키...
'id' // cars 테이블의 로컬키...
);
}
}

Has Many Through

"has-many-through" 관계는 중간 모델을 통해 먼 거리의 연관 데이터를 간편하게 액세스할 수 있게 해줍니다. 예를 들어, Laravel Vapor와 같은 배포 플랫폼을 만든다고 가정합니다. Project 모델에서 중간에 있는 Environment 모델을 거쳐, 여러 개의 Deployment 모델을 연결해야 할 수 있습니다. 아래와 같은 테이블 구조가 필요합니다.

projects
id - integer
name - string

environments
id - integer
project_id - integer
name - string

deployments
id - integer
environment_id - integer
commit_hash - string

이제 테이블 구조를 살펴봤으니, Project 모델에서 연관관계를 아래와 같이 정의할 수 있습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Project extends Model
{
/**
* 이 프로젝트의 모든 배포를 반환합니다.
*/
public function deployments()
{
return $this->hasManyThrough(Deployment::class, Environment::class);
}
}

첫 번째 인자로 최종적으로 접근할 모델명을, 두 번째 인자로 중간 모델명을 전달합니다.

Deployment 모델의 테이블에는 project_id 컬럼이 존재하지 않지만, hasManyThrough 관계를 활용하면 $project->deployments로 해당 프로젝트의 모든 배포를 손쉽게 조회할 수 있습니다. 이를 위해 Eloquent는 Environment 모델의 project_id 컬럼을 활용해 관련 환경들의 id를 찾고, 이 id들을 기준으로 Deployment 데이터를 조회합니다.

키 네이밍 규칙

Eloquent의 기본 외래키 네이밍 규칙이 여기서도 사용됩니다. 만약 직접 키를 지정하고 싶다면, hasManyThrough 메서드의 세 번째, 네 번째 인자를 사용하세요. 세 번째 인자는 중간 모델(environments)의 외래키, 네 번째 인자는 최종 모델(deployments)의 외래키, 다섯 번째 인자는 projects 테이블의 로컬키, 여섯 번째 인자는 environments 테이블의 로컬키입니다.

class Project extends Model
{
public function deployments()
{
return $this->hasManyThrough(
Deployment::class,
Environment::class,
'project_id', // environments 테이블의 외래키...
'environment_id', // deployments 테이블의 외래키...
'id', // projects 테이블의 로컬키...
'id' // environments 테이블의 로컬키...
);
}
}

다대다 연관관계

다대다(many-to-many) 관계는 hasOne이나 hasMany에 비해 다소 복잡할 수 있습니다. 대표적인 예로 사용자가 여러 역할(role)을 가질 수 있고, 그 역할이 여러 사용자에게 공유되는 경우를 들 수 있습니다. 예를 들어 어떤 사용자는 "Author"와 "Editor" 역할을 가질 수 있지만, 이 역할은 다른 사용자에도 할당될 수 있습니다. 즉, 한 사용자가 여러 역할을 가질 수 있고, 한 역할도 여러 사용자와 연관될 수 있습니다.

테이블 구조

이 관계를 정의하려면 세 개의 데이터베이스 테이블이 필요합니다: users, roles, 그리고 role_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

모델 구조

다대다(many-to-many) 연관관계는 belongsToMany 메서드의 반환값을 리턴하는 메서드를 작성하여 정의합니다. belongsToMany 메서드는 애플리케이션의 모든 Eloquent 모델이 상속받는 Illuminate\Database\Eloquent\Model 기본 클래스에서 제공됩니다. 예를 들어, User 모델에 roles 메서드를 정의해보겠습니다. 이 메서드의 첫 번째 인수에는 연관된 모델 클래스명을 전달합니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
/**
* 사용자에 속한 역할 목록을 가져옵니다.
*/
public function roles()
{
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');

연관관계의 반대편(Inverse) 정의하기

다대다 연관관계의 "반대편"을 정의하려면, 연관된 모델에 역시 belongsToMany 메서드의 반환값을 리턴하는 메서드를 정의하면 됩니다. 사용자/역할 예제를 완성해보면, 이번에는 Role 모델에 users 메서드를 정의할 수 있습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
/**
* 역할에 속한 사용자 목록을 가져옵니다.
*/
public function users()
{
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 모델에는 두 관련 모델의 키만 포함됩니다. 만약 중간 테이블에 추가적인 컬럼이 있다면, 연관관계를 정의할 때 해당 컬럼명을 withPivot 메서드로 명시적으로 지정해야 합니다.

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

Eloquent가 중간 테이블의 created_atupdated_at 타임스탬프를 자동으로 관리하게 하고 싶다면, 연관관계를 정의할 때 withTimestamps 메서드를 호출하면 됩니다.

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

[!NOTE] 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;
}

중간 테이블 컬럼을 이용한 쿼리 필터링

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');

커스텀 중간 테이블 모델 정의하기

다대다 연관관계의 중간 테이블을 표현하는 커스텀 모델을 정의하고 싶다면, 연관관계 정의 시 using 메서드를 사용하면 됩니다. 커스텀 피벗(pivot) 모델을 활용하면, 피벗 모델에 추가적인 메서드도 정의할 수 있습니다.

커스텀 다대다 피벗 모델은 반드시 Illuminate\Database\Eloquent\Relations\Pivot 클래스를 상속해야 하고, 커스텀 다형 다대다 피벗 모델은 Illuminate\Database\Eloquent\Relations\MorphPivot 클래스를 상속해야 합니다. 예를 들어, Role 모델에서 커스텀 RoleUser 피벗 모델을 사용하는 코드는 다음과 같습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
/**
* 역할에 속한 사용자 목록을 가져옵니다.
*/
public function users()
{
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
{
//
}

[!NOTE] 피벗 모델에는 SoftDeletes 트레이트를 사용할 수 없습니다. 피벗 레코드에 소프트 삭제 기능이 필요하다면 피벗 모델 대신 실제 Eloquent 모델로 전환하는 방식을 고려하세요.

커스텀 피벗 모델과 자동 증가 ID

만약 커스텀 피벗 모델에서 자동 증가(primary key auto-increment)되는 ID 컬럼을 사용한다면, 해당 모델 클래스 내에 incrementing 속성을 반드시 true로 지정해야 합니다.

/**
* ID가 자동 증가하는지 여부를 지정합니다.
*
* @var bool
*/
public $incrementing = true;

다형적(Polymorphic) 관계

다형적(polymorphic) 관계란 하나의 자식 모델이 단일 연관관계를 통해 여러 종류의 다른 모델에 속할 수 있도록 하는 방식입니다. 예를 들어, 사용자가 블로그 글과 영상을 공유하는 애플리케이션을 만든다고 가정해 봅시다. 이때 Comment 모델은 PostVideo 모델 모두와 관계를 맺을 수 있습니다.

일대일(One To One) 다형적(Polymorphic) 관계

테이블 구조

일대일 다형적 관계는 일반적인 일대일 관계와 비슷하지만, 자식 모델이 단일 연관관계를 통해 다양한 종류의 부모 모델에 속할 수 있다는 점이 다릅니다. 예를 들어, 블로그 PostUser가 하나의 Image 모델과 다형적 관계를 맺을 수 있습니다. 일대일 다형적 관계를 활용하면, 여러 게시글이나 사용자가 고유한 이미지들을 한 테이블에서 공유하면서 관리할 수 있습니다. 테이블 구조 예시는 다음과 같습니다.

posts
id - integer
name - string

users
id - integer
name - string

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

images 테이블의 imageable_idimageable_type 컬럼을 주목하세요. imageable_id에는 해당 이미지의 부모가 되는 게시글 또는 사용자의 ID가 저장되고, imageable_type에는 부모 모델의 클래스명이 저장됩니다. 이 imageable_type 컬럼이 Eloquent에서 어떤 "종류"의 부모 모델을 반환해야 하는지 결정하는 데 사용됩니다. 이 경우, 컬럼 값은 App\Models\Post 또는 App\Models\User가 됩니다.

모델 구조

이 연관관계를 구축하기 위해 필요한 모델 정의는 다음과 같습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Image extends Model
{
/**
* 부모 imageable(사용자 또는 게시글) 모델을 가져옵니다.
*/
public function imageable()
{
return $this->morphTo();
}
}

class Post extends Model
{
/**
* 게시글의 이미지를 가져옵니다.
*/
public function image()
{
return $this->morphOne(Image::class, 'imageable');
}
}

class User extends Model
{
/**
* 사용자의 이미지를 가져옵니다.
*/
public function image()
{
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()
{
return $this->morphTo(__FUNCTION__, 'imageable_type', 'imageable_id');
}

일대다(One To Many) 다형적(Polymorphic) 관계

테이블 구조

일대다 다형적 관계는 기본적인 일대다 관계와 비슷하지만, 자식 모델이 하나의 연관관계를 통해 여러 종류의 부모 모델에 속할 수 있습니다. 예를 들어, 애플리케이션의 사용자들이 '게시글'과 '비디오'에 모두 "댓글"을 남길 수 있다고 가정해 보겠습니다. 다형적 관계를 활용하면, 하나의 comments 테이블에서 게시글과 비디오의 모든 댓글을 저장할 수 있습니다. 아래는 필요한 테이블 구조 예시입니다.

posts
id - integer
title - string
body - text

videos
id - integer
title - string
url - string

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

모델 구조

이 연관관계를 구축하기 위한 모델 정의는 다음과 같습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
/**
* 부모 commentable(게시글 또는 비디오) 모델을 가져옵니다.
*/
public function commentable()
{
return $this->morphTo();
}
}

class Post extends Model
{
/**
* 게시글의 모든 댓글을 가져옵니다.
*/
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}

class Video extends Model
{
/**
* 비디오의 모든 댓글을 가져옵니다.
*/
public function comments()
{
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인지에 따라 각각의 인스턴스를 반환합니다.

일대다 중 하나(One Of Many) 다형적 관계

모델이 여러 관련 모델을 가질 수 있지만, 이 중에서 "최신" 또는 "가장 오래된" 연관된 모델을 간편하게 조회하고 싶을 때가 있습니다. 예를 들어, User 모델이 여러 개의 Image 모델과 연관되어 있을 때, 사용자가 마지막에 업로드한 이미지만을 편리하게 가져오고 싶을 수 있습니다. 이런 기능은 morphOne 관계와 ofMany 관련 메서드를 조합하여 구현할 수 있습니다.

/**
* 사용자의 가장 최근 이미지를 가져옵니다.
*/
public function latestImage()
{
return $this->morphOne(Image::class, 'imageable')->latestOfMany();
}

마찬가지로, 가장 오래된 이미지(최초 업로드 이미지 등)를 조회하는 메서드도 아래와 같이 정의할 수 있습니다.

/**
* 사용자의 가장 오래된 이미지를 가져옵니다.
*/
public function oldestImage()
{
return $this->morphOne(Image::class, 'imageable')->oldestOfMany();
}

latestOfManyoldestOfMany 메서드는 기본적으로 관련 모델의 기본 키(정렬이 가능한 값)를 기준으로 가장 최신 또는 가장 오래된 인스턴스를 조회합니다. 하지만, 더 복잡한 조건으로 단 하나의 연관 모델을 선택해서 가져오고 싶을 수도 있습니다.

예를 들어, ofMany 메서드를 사용하면 사용자의 "가장 많은 좋아요를 받은" 이미지를 조회할 수 있습니다. ofMany의 첫 번째 인수로 정렬 기준이 될 컬럼명을, 두 번째 인수로 집계 함수(min 또는 max)를 지정합니다.

/**
* 사용자의 가장 인기있는 이미지를 가져옵니다.
*/
public function bestImage()
{
return $this->morphOne(Image::class, 'imageable')->ofMany('likes', 'max');
}

[!TIP] 더 복잡한 "one of many" 연관관계도 구현할 수 있습니다. 자세한 내용은 has one of many 문서를 참고하시기 바랍니다.

다대다(Polymorphic) 관계

테이블 구조

다대다(polymorphic) 관계는 "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

[!TIP] 다형 다대다 관계 예제를 본격적으로 다루기 전에, 일반적인 다대다 관계 문서를 먼저 학습하면 더욱 이해가 잘 됩니다.

모델 구조

이제 각 모델에 연관관계를 정의할 차례입니다. PostVideo 모델 모두 기본 Eloquent 모델 클래스에서 제공하는 morphToMany 메서드를 호출하는 tags 메서드를 포함해야 합니다.

morphToMany 메서드는 연관된 모델명과 "연관관계 이름"을 인수로 받습니다. 중간 테이블의 이름과 키를 기준으로 이 연관관계에서는 "taggable"이라는 이름을 사용하게 됩니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
/**
* 게시글에 연결된 모든 태그를 가져옵니다.
*/
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
}

연관관계의 반대편(Inverse) 정의하기

이제 Tag 모델에 각 부모 모델에 해당하는 메서드를 각각 정의해야 합니다. 즉, 이 예시에서는 postsvideos 메서드를 만들어야 하며, 두 메서드 모두 morphedByMany 메서드의 반환값을 리턴해야 합니다.

morphedByMany는 연관된 모델명과 "연관관계 이름"을 인수로 받습니다. 중간 테이블 및 관련 키에서 이미 사용했던 "taggable"이라는 이름을 그대로 사용합니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
/**
* 이 태그가 연결된 모든 게시글을 가져옵니다.
*/
public function posts()
{
return $this->morphedByMany(Post::class, 'taggable');
}

/**
* 이 태그가 연결된 모든 비디오를 가져옵니다.
*/
public function videos()
{
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) {
//
}

사용자 정의 다형성 타입

기본적으로 라라벨은 연관된 모델의 "타입" 정보를 저장할 때 완전히 한정된 클래스명을 사용합니다. 앞서 살펴본 일대다 다형성 관계에서 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',
]);

enforceMorphMap 메서드는 App\Providers\AppServiceProvider 클래스의 boot 메서드에서 호출하거나, 별도의 서비스 프로바이더를 만들어 정의해도 됩니다.

런타임에 특정 모델의 다형성 별칭(별칭, alias)을 확인하려면, 해당 모델의 getMorphClass 메서드를 사용할 수 있습니다. 반대로, 다형성 맵에 등록된 별칭으로부터 완전한 클래스명을 얻고 싶다면 Relation::getMorphedModel 메서드를 사용하면 됩니다.

use Illuminate\Database\Eloquent\Relations\Relation;

$alias = $post->getMorphClass();

$class = Relation::getMorphedModel($alias);

[!NOTE] 기존 애플리케이션에 "morph map"을 추가할 때에는, 데이터베이스의 모든 다형성 *_type 컬럼 값(완전한 클래스명이 저장되어 있던 값들)을 반드시 맵에서 정의한 "별칭" 값으로 변경해주어야 합니다.

동적 관계(Dynamic Relationships)

resolveRelationUsing 메서드를 사용하면 Eloquent 모델 간의 관계를 런타임에 동적으로 정의할 수 있습니다. 일반적인 애플리케이션 개발에서는 주로 권장되지 않지만, 라라벨 패키지 개발 등에서는 유용할 수 있습니다.

resolveRelationUsing의 첫 번째 인수로 원하는 관계명을 지정하고, 두 번째 인수로는 모델 인스턴스를 받아 유효한 Eloquent 관계 정의를 반환하는 클로저를 전달해야 합니다. 보통 이런 동적 관계 정의는 서비스 프로바이더 클래스의 boot 메서드에서 수행합니다.

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

Order::resolveRelationUsing('customer', function ($orderModel) {
return $orderModel->belongsTo(Customer::class, 'customer_id');
});

[!NOTE] 동적 관계를 정의할 때는 Eloquent 관계 메서드에 키 이름을 명확하게 지정하는 것이 좋습니다.

관계 데이터 쿼리하기

모든 Eloquent 관계는 메서드로 정의하므로, 실제 쿼리를 실행하지 않고도 관계 인스턴스를 얻을 수 있습니다. 또한 모든 종류의 Eloquent 관계는 쿼리 빌더로 동작하므로, 데이터베이스에 최종적으로 쿼리를 실행하기 전까지 관계 쿼리에 다양한 조건을 메서드 체이닝 방식으로 추가할 수 있습니다.

예를 들어, 블로그 애플리케이션에서 User 모델이 여러 개의 Post 모델을 가지고 있다고 가정하겠습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
/**
* 해당 사용자의 모든 게시글(post)을 가져옵니다.
*/
public function posts()
{
return $this->hasMany(Post::class);
}
}

이제 다음과 같이 posts 관계에 쿼리 조건을 체이닝하여 추가할 수 있습니다.

use App\Models\User;

$user = User::find(1);

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

라라벨의 쿼리 빌더 메서드는 관계 쿼리에도 모두 사용할 수 있으니, 쿼리 빌더 문서를 확인해 다양한 메서드를 익히시기 바랍니다.

관계 쿼리에서 orWhere 체이닝 시 주의점

앞서 예제에서 보았듯이, 관계 쿼리에 조건을 자유롭게 추가할 수 있습니다. 하지만, 관계 쿼리에 orWhere 절을 체이닝할 때에는 주의가 필요합니다. orWhere 절은 관계의 기본 조건과 같은 수준에서 묶이기 때문입니다.

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

위의 코드가 생성하는 SQL 문을 보면, or절 때문에 "100표 이상 받은 모든 사용자(user_id와 관계없이)"의 게시글도 결과에 포함되게 됩니다. 즉, 원래 특정 사용자에 한정되어야 할 쿼리 범위가 벗어나게 됩니다.

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)

관계 메서드 vs 동적 속성

관계 쿼리에 별도의 제약 조건을 추가하지 않는다면, 관계를 마치 모델의 일반 속성처럼 접근할 수 있습니다. 앞서 살펴본 UserPost 예제를 이어, 한 사용자의 모든 게시글에 접근하는 코드는 다음과 같습니다.

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();

연산자와 개수를 지정하면 더욱 세밀한 조건으로 결과를 조정할 수 있습니다.

// 3개 이상의 댓글이 달린 게시글만 조회
$posts = Post::has('comments', '>=', 3)->get();

중첩된 has 조건문은 "점(dot) 표기법"을 활용해 손쉽게 작성할 수 있습니다. 예를 들어, 최소 한 개 이상의 이미지가 첨부된 댓글이 있는 모든 게시글을 조회할 수 있습니다.

// 이미지가 첨부된 댓글이 있는 게시글만 조회
$posts = Post::has('comments.images')->get();

더 강력한 제약 조건이 필요하다면, whereHasorWhereHas 메서드를 사용해 관계 쿼리에 추가적인 조건도 지정할 수 있습니다. 예를 들어 댓글의 내용을 검사하는 경우가 이에 해당합니다.

use Illuminate\Database\Eloquent\Builder;

// 내용에 'code%'가 포함된 댓글이 최소 한 개 이상 달린 게시글을 조회
$posts = Post::whereHas('comments', function (Builder $query) {
$query->where('content', 'like', 'code%');
})->get();

// 내용에 'code%'가 포함된 댓글이 최소 10개 이상 달린 게시글을 조회
$posts = Post::whereHas('comments', function (Builder $query) {
$query->where('content', 'like', 'code%');
}, '>=', 10)->get();

[!NOTE] 현재 Eloquent는 데이터베이스 간의 관계 존재 여부 쿼리를 지원하지 않습니다. 반드시 같은 데이터베이스 내에 관계가 존재해야 합니다.

인라인 관계 존재 쿼리

관계 쿼리에 단일 where 조건을 곁들여 있고 싶을 때는 whereRelationwhereMorphRelation 메서드를 활용하면 더 간결한 코드를 작성할 수 있습니다. 예를 들어, 승인되지 않은 댓글이 달린 게시글을 다음과 같이 조회할 수 있습니다.

use App\Models\Post;

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

물론, 쿼리 빌더의 where 메서드처럼 연산자도 지정할 수 있습니다.

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

관계 부재 쿼리

모델 레코드를 조회할 때 특정 관계가 존재하지 않는 경우로 결과를 제한하고 싶을 때도 있습니다. 예를 들어, 댓글이 한 개도 없는 게시글만 조회하려면, doesntHave 또는 orDoesntHave 메서드에 관계명을 전달합니다.

use App\Models\Post;

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

더 정교한 제약 조건이 필요하다면 whereDoesntHaveorWhereDoesntHave 메서드를 활용해 게시글에 달린 댓글의 내용을 기준으로 필터링할 수 있습니다.

use Illuminate\Database\Eloquent\Builder;

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

"점(dot) 표기법"을 사용하면 중첩 관계에서도 쿼리가 가능합니다. 아래 예시는 댓글이 없는 게시글을 조회하지만, "밴 처리되지 않은(banned=0) 작성자가 단 댓글"은 있는 게시글 또한 결과에 포함됩니다.

use Illuminate\Database\Eloquent\Builder;

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

다형성(Morph To) 관계 쿼리

"morph to" 관계에 대해 존재 쿼리를 작성하려면, whereHasMorphwhereDoesntHaveMorph 메서드를 사용하면 됩니다. 이들 메서드의 첫 번째 인수는 관계명, 두 번째 인수는 쿼리에 포함하고자 하는 관련 모델명들이며, 마지막 인수로는 관계 쿼리를 추가로 커스터마이즈 할 수 있는 클로저를 전달할 수 있습니다.

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

// 제목에 'code%'가 포함된 게시글(Post)이나 비디오(Video)에 연관된 댓글 조회
$comments = Comment::whereHasMorph(
'commentable',
[Post::class, Video::class],
function (Builder $query) {
$query->where('title', 'like', 'code%');
}
)->get();

// 제목에 'code%'가 포함되지 않은 게시글(Post)에 연관된 댓글만 조회
$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, $type) {
$column = $type === Post::class ? 'content' : 'title';

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

모든 다형성 관련 모델 쿼리

특정 다형성 모델 배열을 전달하는 대신, *(애스터리스크)를 와일드카드로 넘길 수 있습니다. 이 경우 라라벨은 데이터베이스에서 존재하는 모든 다형성 타입을 조회하며, 이를 위해 별도의 쿼리가 추가로 실행됩니다.

use Illuminate\Database\Eloquent\Builder;

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

연관 모델 집계

연관 모델 개수 세기

관계에 속한 모델의 전체 개수를 가져오되, 실제 모델 전체를 로드하지 않고 싶을 수도 있습니다. 이럴 때는 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;

또한, 관계 카운트를 별칭(alias)으로 지정할 수도 있어, 동일한 관계에 대해 여러 개의 카운트를 구할 수 있습니다.

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;

카운트의 지연 로딩(Deferred Count Loading)

loadCount 메서드를 사용하면, 부모 모델을 이미 조회한 후에 관계 개수만 별도로 추가 로딩할 수 있습니다.

$book = Book::first();

$book->loadCount('genres');

카운트 쿼리에 추가 제약 조건을 지정하고 싶으면, 배열의 키에 관계명을, 값으로 쿼리 빌더를 받는 클로저를 넘겨주면 됩니다.

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

관계 카운트와 커스텀 select 조합

withCountselect 구문과 함께 쓸 때는 반드시 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 처럼, 이렇게 집계된 정보를 조회 후에 별도로 로드할 수도 있습니다.

$post = Post::first();

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

만약 이러한 집계 메서드들을 select와 조합하고자 한다면, 역시 select 이후에 집계 메서드를 호출해야 합니다.

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

Morph To 관계에서 연관 모델 개수 세기

"morph to" 관계뿐만 아니라 각 관계가 반환할 수 있는 여러 엔티티의 카운트도 즉시 로딩하여 보고 싶을 때는, with와 morphTo 관계의 morphWithCount 메서드를 조합하면 됩니다.

예를 들어, Photo, Post 모델이 각각 ActivityFeed 모델을 만들 수 있다고 합시다. ActivityFeed 모델에는 부모 Photo 또는 Post 모델에 접근할 수 있는 parentable morphTo 관계가 있습니다. 추가적으로, Photo 모델은 여러 Tag와, Post 모델은 여러 Comment와 각각 연관 관계를 맺고 있다고 가정합니다.

이렇게 설정된 경우, ActivityFeed 인스턴스를 조회하면서 각 인스턴스의 부모 parentable 모델을 즉시 로딩하고, 각 부모마다 연결된 태그나 댓글 개수까지 함께 조회하려면 다음과 같이 하면 됩니다.

use Illuminate\Database\Eloquent\Relations\MorphTo;

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

Morph To 관계에서의 지연 카운트 로딩

이미 여러 개의 ActivityFeed 모델을 먼저 조회했다면, 나중에 각 parentable 부모 모델의 하위 관계 개수를 지연 로딩 방식으로 가져올 수도 있습니다. 이때는 loadMorphCount 메서드를 사용하면 됩니다.

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

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

즉시 로딩(Eager Loading)

Eloquent 관계에 속성처럼 접근하면 연관된 데이터는 "지연 로딩"됩니다. 즉, 실제로 해당 속성에 접근할 때 쿼리가 발생합니다. 반면, Eloquent는 부모 모델을 쿼리할 때 특정 관계를 "즉시 로딩(eager loading)"할 수 있는 기능도 지원합니다. 즉시 로딩을 활용하면 이른바 "N + 1" 쿼리 문제를 해결할 수 있습니다. 이 문제를 설명하기 위해, 한 Book 모델이 Author 모델에 "belongs to" 관계를 맺고 있다고 가정해 보겠습니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
/**
* 책의 저자(Author)를 반환합니다.
*/
public function author()
{
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();

중첩 관계 즉시 로딩

관계의 또 다른 관계까지 즉시 로딩하고 싶을 때는 "점(dot) 표기법"을 활용할 수 있습니다. 예를 들어, 모든 책의 저자와, 저자의 연락처까지 즉시 로딩하려면 다음과 같이 작성합니다.

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

중첩 morphTo 관계의 즉시 로딩

morphTo 관계와, 해당 morphTo가 반환할 수 있는 다양한 엔티티의 추가 관계까지 함께 즉시 로딩하려면, with와 morphTo 관계의 morphWith 메서드를 결합해서 사용할 수 있습니다. 다음 예시를 참고하세요.

<?php

use Illuminate\Database\Eloquent\Model;

class ActivityFeed extends Model
{
/**
* 이 액티비티 피드의 부모를 반환합니다.
*/
public function parentable()
{
return $this->morphTo();
}
}

이 예시에서 Event, Photo, Post 모델이 ActivityFeed 모델을 생성할 수 있다고 가정합니다. 또한 Event 모델은 Calendar와, PhotoTag와, PostAuthor와 각각 연결되어 있습니다.

이런 모델/관계 구성을 한다면, 아래 코드처럼 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();

특정 컬럼만 Eager Loading 하기

연관관계를 통해 데이터를 불러올 때, 항상 모든 컬럼이 필요한 것은 아닙니다. 이런 경우, Eloquent에서는 연관관계에서 어떤 컬럼만 조회할지 명시적으로 지정할 수 있습니다.

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

[!NOTE] 이 기능을 사용할 때는 반드시 id 컬럼과 적절한 외래키 컬럼을 컬럼 목록에 포함시켜야 합니다.

기본적으로 Eager Loading 적용하기

모델을 조회할 때마다 항상 특정 연관관계를 로드하고 싶을 때가 있습니다. 이럴 때는 모델에 $with 속성을 정의하면 됩니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
/**
* 항상 로드할 연관관계 목록
*
* @var array
*/
protected $with = ['author'];

/**
* 이 책을 쓴 저자를 반환합니다.
*/
public function author()
{
return $this->belongsTo(Author::class);
}

/**
* 책의 장르를 반환합니다.
*/
public function genre()
{
return $this->belongsTo(Genre::class);
}
}

단일 쿼리에서 $with 속성에 지정된 항목을 제외하고 싶다면 without 메서드를 사용할 수 있습니다.

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

단일 쿼리에서 $with에 지정된 모든 항목을 원하는 값으로 다 덮어쓰고 싶으면 withOnly 메서드를 사용합니다.

$books = Book::withOnly('genre')->get();

Eager Loading에 조건 추가하기

연관관계를 eager load 하면서 동시에 해당 쿼리에 추가 조건을 걸고 싶을 수도 있습니다. 이럴 때에는 with 메서드에 배열을 전달하고, 배열의 키는 연관관계 이름, 값은 조건을 추가하는 클로저로 작성할 수 있습니다.

use App\Models\User;

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

이 예시에서는, 게시글의 title 컬럼에 code라는 단어가 포함된 게시글만 eager load 하게 됩니다. 또한, 쿼리 빌더의 다른 메서드들을 활용해 eager loading 쿼리를 원하는 대로 커스터마이즈할 수 있습니다.

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

[!NOTE] limittake 쿼리 빌더 메서드는 eager load 제약 조건에서 사용할 수 없습니다.

morphTo 연관관계 Eager Loading 제약 조건 추가하기

morphTo 연관관계를 eager load 할 때는, Eloquent가 각 관련된 모델별로 여러 쿼리를 실행합니다. 이 경우 각 쿼리에 제약 조건을 추가하려면, MorphTo 관계의 constrain 메서드를 이용할 수 있습니다:

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

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

위 예시에서 Eloquent는 숨겨지지 않은(Post의 경우) 게시글과, type 값이 "educational"인 비디오만 eager load 하게 됩니다.

Lazy Eager Loading

간혹 상위(부모) 모델을 이미 조회한 뒤에 연관관계의 eager load가 필요한 경우가 있습니다. 예를 들어, 관련 모델을 로드할지 동적으로 결정해야 할 때 이런 방식이 유용합니다.

use App\Models\Book;

$books = Book::all();

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

eager load 쿼리에 조건을 추가해야 한다면, 로드할 연관관계를 키로, 클로저를 값으로 가지는 배열을 전달할 수 있습니다.

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

이미 로드되지 않은 관계만 로드하고자 한다면 loadMissing 메서드를 사용하세요.

$book->loadMissing('author');

중첩 Lazy Eager Loading & morphTo

morphTo 연관관계뿐만 아니라, 그 안에 등장할 수 있는 다양한 엔티티의 중첩 관계까지 eager load 하고 싶다면 loadMorph 메서드를 사용할 수 있습니다.

이 메서드는 첫 번째 인자로 morphTo 관계의 이름을, 두 번째 인자로 모델 및 해당 연관관계 목록의 배열을 받습니다. 아래 예시를 참고하세요.

<?php

use Illuminate\Database\Eloquent\Model;

class ActivityFeed extends Model
{
/**
* Activity Feed 기록의 상위(parent) 객체를 반환합니다.
*/
public function parentable()
{
return $this->morphTo();
}
}

여기서 예를 들어, Event, Photo, Post 모델들이 모두 ActivityFeed를 생성할 수 있다고 가정합니다. 또한 Event 모델은 Calendar 모델과, Photo 모델은 Tag 모델과, PostAuthor 모델과 각각 연관되어 있다고 하면, 다음과 같이 사용할 수 있습니다.

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

Lazy Loading 방지하기

앞서 다룬 것처럼, eager loading을 적극적으로 활용하면 애플리케이션의 성능을 크게 높일 수 있습니다. 그래서 라라벨에서는 관계의 lazy loading을 항상 방지하도록 설정할 수 있습니다. 이를 위해 Eloquent 기본 모델 클래스의 preventLazyLoading 메서드를 사용합니다. 보통 이 코드는 애플리케이션의 AppServiceProvider 클래스의 boot 메서드에서 호출하는 것이 일반적입니다.

preventLazyLoading 메서드는 lazy loading을 방지할지 여부를 나타내는 (불리언) 인자를 선택적으로 받습니다. 예를 들어, 프로덕션 환경이 아닐 때만 lazy loading을 막고 싶을 수도 있습니다. 이런 경우에도 프로덕션 환경에서는 기존 코드가 영향을 받지 않도록 처리할 수 있습니다.

use Illuminate\Database\Eloquent\Model;

/**
* 애플리케이션 서비스를 부트스트랩합니다.
*
* @return void
*/
public function boot()
{
Model::preventLazyLoading(! $this->app->isProduction());
}

lazy loading을 방지하도록 설정하면, Eloquent가 관계를 lazy load 하려고 시도할 때마다 Illuminate\Database\LazyLoadingViolationException 예외가 발생하게 됩니다.

lazy loading 위반 발생 시의 동작을 handleLazyLoadingViolationsUsing 메서드로 커스터마이즈할 수도 있습니다. 예를 들어, 예외를 발생시키는 대신 로그만 남기도록 하려면 아래와 같이 할 수 있습니다.

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

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 관계에 동적 프로퍼티로 접근하지 않고, 메서드로 호출해 관계 인스턴스를 얻은 뒤 save를 사용했습니다. 이때 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 메서드는 주어진 모델 인스턴스들을 데이터베이스에 저장하기는 하지만, 이미 로드된 부모 모델의 in-memory(메모리 상의) 관계에 새로 저장한 모델을 자동으로 추가하지는 않습니다. 저장 이후에 해당 관계에 바로 접근해야 한다면, 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();

create 메서드

savesaveMany 외에도, 속성 배열을 전달해 새 모델을 생성하고 데이터베이스에 바로 저장하는 create 메서드를 사용할 수도 있습니다. 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.'],
]);

또한, 연관관계에 대해 findOrNew, firstOrNew, firstOrCreate, updateOrCreate 등의 메서드도 사용할 수 있습니다.

[!TIP] 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();

다대다(Many To Many) 연관관계

Attach / Detach

Eloquent는 다대다 연관관계를 손쉽게 다룰 수 있는 여러 메서드를 제공합니다. 예를 들어, 한 사용자가 여러 역할(roles)을 가질 수 있고, 역할도 여러 사용자를 가질 수 있는 구조라면, 중간 테이블에 데이터를 기록하려면 attach 메서드를 사용할 수 있습니다.

use App\Models\User;

$user = User::find(1);

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

attach 시 중간 테이블에 추가로 기록할 데이터를 배열 형태로 함께 전달할 수도 있습니다.

$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는 중간 테이블에서 제거됩니다. 즉, sync 이후에는 전달한 ID들만 관계에 남게 됩니다.

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

동기화 시 중간 테이블에 저장할 추가 데이터를 함께 전달할 수도 있습니다.

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

모든 동기화된 ID에 동일한 중간 테이블 데이터를 기록하고 싶다면 syncWithPivotValues 메서드를 쓸 수 있습니다.

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

주어진 배열에 포함되지 않은 기존 ID를 분리(detach)하지 않고 유지하고 싶다면, syncWithoutDetaching을 사용하세요.

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

연관 데이터 토글(Toggle)

다대다 관계에는 토글(toggle) 메서드도 있습니다. 이 메서드는 전달된 ID가 이미 연결되어 있다면 분리하고, 연결되어 있지 않다면 새로 attach 합니다.

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

중간 테이블의 데이터 수정하기

이미 존재하는 중간 테이블의 레코드를 수정하려면, updateExistingPivot 메서드를 사용할 수 있습니다. 이 메서드는 중간 테이블의 외래키, 그리고 수정할 속성 배열을 받습니다.

$user = User::find(1);

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

부모 타임스탬프(Timestamp) 자동 업데이트

belongsTo 또는 belongsToMany 관계를 정의할 때(예: CommentPost), 자식 모델이 수정될 때 부모 모델의 타임스탬프를 자동으로 업데이트하는 것이 유용할 때가 있습니다.

예를 들어, Comment 모델을 수정하면 소유하고 있는 Postupdated_at 값도 현재 시간으로 자동 업데이트하고 싶을 수 있습니다. 이런 경우, 자식 모델에 touches 속성을 추가하고, 업데이트가 필요한 관계 이름을 배열로 지정하면 됩니다.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
/**
* 변경될 때 타임스탬프를 touch할 관계 목록
*
* @var array
*/
protected $touches = ['post'];

/**
* 이 댓글이 소속된 게시글을 반환합니다.
*/
public function post()
{
return $this->belongsTo(Post::class);
}
}

[!NOTE] 부모 모델의 타임스탬프는 Eloquent의 save 메서드로 자식 모델이 수정될 때만 자동으로 업데이트됩니다.