resolveRelationUsing()으로 관계의 생성 방법을 등록할 수 있습니다. 모델 파일을 직접 수정하지 않고도 일반 관계와 똑같이 조회·Eager 로딩·존재 조건에 의한 검색을 사용할 수 있습니다.
이 페이지는 Laravel Framework v13.40.0의 공식 구현을 확인한 내용입니다. 관계의 기본은 Eloquent 관계 입문을, 프로바이더의 로드는 패키지 자동 검출을 참조하세요.
일반 정의와 구분해서 사용하기
공식 문서에서는 일반적인 애플리케이션 개발에는 일반 관계 메서드를 권장하며, 동적 정의는 패키지 개발 등에서 유용할 수 있다고 설명합니다.
동적 관계 프로퍼티와 모델 외부에서 관계 정의를 등록하는 기능은 서로 다릅니다. 또한
resolveRelationUsing()은 임의의 메서드를 추가하는 Macro가 아닙니다. 콜백은 Eloquent의 Relation 인스턴스를 반환하도록 설계합니다.
사용자가 선택한 모델에 등록하기
예시로, 사용하는 애플리케이션의 주문 모델에acmeNotes라는 hasMany를 추가합니다. 패키지의 네임스페이스 Acme\OrderNotes\가 Composer의 PSR-4로 src/에 대응하고, 일반적인 비지연 서비스 프로바이더로 로드되는 구성을 가정합니다.
설정과 관계 대상 모델 준비하기
config/acme-order-notes.php의 초기값은 null로 합니다. 설치만으로는 애플리케이션의 모델에 관계를 등록하지 않습니다.
src/Models/Note.php는 관계 대상 모델입니다.
id가 있고, acme_order_notes 테이블에 id·order_id·body·타임스탬프가 있다고 가정합니다. order_id에는 인덱스를 준비합니다. UUID 등을 사용하는 경우에는 관련 컬럼의 타입도 일치시킵니다.
동적 관계의 등록은 테이블이나 외래 키 제약을 생성하지 않습니다. 패키지 마이그레이션의 공개와 업데이트와는 별개의 처리입니다. 아래 프로바이더에도 DB 접근이나 마이그레이션 실행을 포함하지 않습니다.
boot에서 생성 방법 등록하기
src/OrderNotesServiceProvider.php에서 설정을 병합하고, boot()에서 대상 모델에 콜백을 등록합니다.
order_model을 \App\Models\Order::class 등 실제로 사용하는 구체 모델로 변경합니다. 프로바이더의 로드는 자동 검출로 설정합니다.
hasMany()에는 외래 키 order_id와 부모 측 키를 명시하고 있습니다. 부모 모델의 클래스 이름이 Purchase이더라도 purchase_id를 추론하게 하지 않고, 패키지의 테이블 정의와 일치시키기 위해서입니다.
일반 관계와 똑같이 사용하기
설정한 주문 모델에 대해 메서드 호출은HasMany를, 프로퍼티 접근은 조회 결과 컬렉션을 반환합니다.
create()에서는 hasMany가 order_id를 설정하므로, 사용자가 전달한 배열에 외래 키를 섞을 필요가 없습니다. 역방향 관계는 자동으로 추가되지 않으므로 필요하면 별도로 정의합니다.
콜백에서는 조회까지 실행하지 않기
콜백의 반환값을get()의 결과나 단일 모델로 하면, 프로퍼티 접근 시 등에서 유효한 관계로 다룰 수 없습니다. 관계의 구성과 데이터 조회를 분리합니다.
Eager 로딩의 Builder::getRelation()은 새 모델 인스턴스로 관계를 구성합니다. 따라서 콜백이 “조회된 주문 ID가 있음”을 전제로 하거나, 그 ID에 따라 다른 관계 정의를 반환하는 설계는 피합니다.
동적으로 등록해도 N+1 문제나 지연 로딩(Lazy Loading)의 제약은 사라지지 않습니다. 여러 모델을 다룰 때는 평소처럼 with() 등으로 관계를 로드합니다.
belongsTo에서는 관계 이름의 추론에도 주의하기
belongsTo()는 관계 이름을 생략하면 호출한 곳을 백트레이스로 추론합니다. 클로저에서 정의할 때는 일반 모델 메서드와 같은 추론에 의존하지 말고, 외래 키·소유자 키와 함께 네 번째 인수인 관계 이름도 명시할 수 있습니다.
다음은 주문 측에 customer_id, 고객 측에 id가 있는 경우의 독립된 예시입니다.
이름 충돌과 상속 규칙 이해하기
HasRelationships는 모델의 클래스 이름과 관계 이름을 키로 클로저를 static 프로퍼티에 저장합니다. 존재하지 않는 메서드 호출을 받은 Model::__call()이 resolver를 검색하고, 대상 모델 인스턴스를 전달해 실행합니다.
그림은 resolver를 찾은 경우의 경로입니다. 기존 메서드나 속성과 충돌하는 경우에는 다른 경로가 된다는 점에 주의합니다.
관계 이름에는
acmeNotes처럼 고유한 이름을 사용하고, 사용자의 메서드·속성·다른 패키지와의 충돌을 확인합니다. method_exists()만으로는 동적 등록의 존재를 확인할 수 없습니다. Eloquent의 isRelation()은 resolver도 확인하지만, 속성과의 충돌이 없다는 것까지는 보장하지 않습니다.
등록 상태와 조회 결과의 수명 구분하기
resolver의 등록과 각 모델이 보유하는 로드된 관계 데이터는 별개입니다.
이미
$order->acmeNotes를 로드한 뒤에 resolver를 덮어써도, 해당 모델이 보유한 결과는 자동으로 갱신되지 않습니다. 다시 가져와야 한다면 관계 결과를 제거한 뒤 load('acmeNotes') 등을 사용합니다.
마찬가지로, Testbench에서 애플리케이션을 다시 만드는 것만으로 resolver가 사라진다고 생각하지 마세요. 활성·비활성을 비교하는 테스트에는 별도의 모델 fixture를 사용하거나 PHPUnit의 프로세스 격리를 사용해, 이전 등록에 대한 의존을 피합니다.
등록과 SQL을 모두 테스트하기
Orchestra Testbench에서는 프로바이더의 등록뿐 아니라 외래 키와 실제 관계 조회를 검증합니다. 다음은tests/DynamicRelationsTest.php의 예시입니다. 패키지의 클래스와 테스트 네임스페이스를 Composer로 로드할 수 있게 하고, PDO·SQLite 드라이버를 준비합니다.
OrderFixture이지만, 외래 키는 order_fixture_id가 아니라 order_id 그대로입니다. 클래스 이름으로부터의 추론에 의존하지 않는다는 것을 확인할 수 있습니다.
패키지의 CI에서는 다음 경계도 추가로 검증합니다.
- 등록되지 않은 별도의 fixture에서,
order_model이null이면 관계를 추가하지 않는 것. - 자식 모델로의 상속과, 자식에만 같은 이름의 resolver를 등록한 경우의 우선순위.
- 기존 메서드나 속성과 충돌한 경우에 의도한 API를 사용할 수 있는 것.
- 관계를 한 번 로드한 모델과 새 모델에서 resolver를 다시 등록한 후의 차이.
- 지원하는 DB에서의 컬럼 타입·인덱스·외래 키 제약과, 사용자가 변경한 기본 키.
참조한 1차 정보
- Laravel 공식 문서: Dynamic Relationships
- Laravel 공식 문서: Package Development
- HasRelationships: 등록·상속 검색과 키 처리
- Model: 동적 호출과 boot 상태의 삭제
- HasAttributes: 속성의 우선순위와 관계 결과의 보유
- Builder: Eager 로딩 시의 관계 구성
- QueriesRelationships: 관계의 존재 조건
- HasOneOrMany: create 시의 외래 키 설정
- Laravel 공식 테스트: DatabaseEloquentDynamicRelationsTest