> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 패키지의 동적 관계 등록

> Laravel 13의 resolveRelationUsing을 분석해 모델을 변경하지 않는 관계 추가, 키 지정, 이름 충돌과 상속, 등록 상태의 수명과 테스트를 설명합니다.

패키지에서 사용하는 애플리케이션의 모델에 관계를 추가하려면 `resolveRelationUsing()`으로 관계의 생성 방법을 등록할 수 있습니다. 모델 파일을 직접 수정하지 않고도 일반 관계와 똑같이 조회·Eager 로딩·존재 조건에 의한 검색을 사용할 수 있습니다.

이 페이지는 Laravel Framework `v13.40.0`의 공식 구현을 확인한 내용입니다. 관계의 기본은 [Eloquent 관계 입문](https://kawax.biz/ko/eloquent-relationships.md)을, 프로바이더의 로드는 [패키지 자동 검출](https://kawax.biz/ko/advanced/package-discovery.md)을 참조하세요.

## 일반 정의와 구분해서 사용하기

공식 문서에서는 일반적인 애플리케이션 개발에는 일반 관계 메서드를 권장하며, 동적 정의는 패키지 개발 등에서 유용할 수 있다고 설명합니다.

| 상황 | 선택 기준 |
| - | - |
| 직접 관리하는 모델에 관계를 추가한다 | 모델에 일반 관계 메서드를 작성한다 |
| 배포 패키지가 사용자의 모델에 관계를 제공한다 | 대상 모델을 설정으로 받고 `resolveRelationUsing()`을 검토한다 |
| 관계 데이터를 프로퍼티로 가져오고 싶을 뿐이다 | 일반 관계에서도 쓸 수 있는 `$order->notes`로 충분하다 |

**동적 관계 프로퍼티**와 모델 외부에서 관계 정의를 등록하는 기능은 서로 다릅니다. 또한 `resolveRelationUsing()`은 임의의 메서드를 추가하는 Macro가 아닙니다. 콜백은 Eloquent의 `Relation` 인스턴스를 반환하도록 설계합니다.

## 사용자가 선택한 모델에 등록하기

예시로, 사용하는 애플리케이션의 주문 모델에 `acmeNotes`라는 `hasMany`를 추가합니다. 패키지의 네임스페이스 `Acme\OrderNotes\`가 Composer의 PSR-4로 `src/`에 대응하고, 일반적인 비지연 서비스 프로바이더로 로드되는 구성을 가정합니다.

### 설정과 관계 대상 모델 준비하기

`config/acme-order-notes.php`의 초기값은 `null`로 합니다. 설치만으로는 애플리케이션의 모델에 관계를 등록하지 않습니다.

```php theme={null}
<?php

return [
    'order_model' => null,
];
```

패키지 측의 `src/Models/Note.php`는 관계 대상 모델입니다.

```php theme={null}
<?php

namespace Acme\OrderNotes\Models;

use Illuminate\Database\Eloquent\Model;

final class Note extends Model
{
    protected $table = 'acme_order_notes';

    protected $fillable = ['body'];
}
```

예시에서는 주문 테이블에 정수 기본 키 `id`가 있고, `acme_order_notes` 테이블에 `id`·`order_id`·`body`·타임스탬프가 있다고 가정합니다. `order_id`에는 인덱스를 준비합니다. UUID 등을 사용하는 경우에는 관련 컬럼의 타입도 일치시킵니다.

> [!NOTE]
>
> 동적 관계의 등록은 테이블이나 외래 키 제약을 생성하지 않습니다. [패키지 마이그레이션의 공개와 업데이트](https://kawax.biz/ko/advanced/package-migrations.md)와는 별개의 처리입니다. 아래 프로바이더에도 DB 접근이나 마이그레이션 실행을 포함하지 않습니다.

### boot에서 생성 방법 등록하기

`src/OrderNotesServiceProvider.php`에서 설정을 병합하고, `boot()`에서 대상 모델에 콜백을 등록합니다.

```php theme={null}
<?php

namespace Acme\OrderNotes;

use Acme\OrderNotes\Models\Note;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;

final class OrderNotesServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__.'/../config/acme-order-notes.php',
            'acme-order-notes',
        );
    }

    public function boot(): void
    {
        $this->publishes([
            __DIR__.'/../config/acme-order-notes.php' => config_path('acme-order-notes.php'),
        ], 'acme-order-notes-config');

        $model = $this->app['config']->get('acme-order-notes.order_model');

        if ($model === null) {
            return;
        }

        if (! is_string($model) || ! is_subclass_of($model, Model::class)) {
            throw new InvalidArgumentException('order_model must be an Eloquent model class.');
        }

        $model::resolveRelationUsing(
            'acmeNotes',
            static fn (Model $order): HasMany => $order->hasMany(
                Note::class,
                'order_id',
                $order->getKeyName(),
            ),
        );
    }
}
```

사용하는 애플리케이션에서는 설정을 공개하고, `order_model`을 `\App\Models\Order::class` 등 실제로 사용하는 구체 모델로 변경합니다. 프로바이더의 로드는 [자동 검출](https://kawax.biz/ko/advanced/package-discovery.md)로 설정합니다.

```bash theme={null}
php artisan vendor:publish --tag=acme-order-notes-config
```

`hasMany()`에는 외래 키 `order_id`와 부모 측 키를 명시하고 있습니다. 부모 모델의 클래스 이름이 `Purchase`이더라도 `purchase_id`를 추론하게 하지 않고, 패키지의 테이블 정의와 일치시키기 위해서입니다.

> [!WARNING]
>
> 대상 모델의 설정은 애플리케이션 관리자가 수행합니다. HTTP 요청 등에서 클래스 이름이나 관계 이름을 직접 받아 요청마다 다시 등록하는 설계는 피하세요. 또한 Eloquent의 기반 `Model`에 등록하면 그 하위 모델 전체에 영향을 줍니다.

## 일반 관계와 똑같이 사용하기

설정한 주문 모델에 대해 메서드 호출은 `HasMany`를, 프로퍼티 접근은 조회 결과 컬렉션을 반환합니다.

```php theme={null}
use App\Models\Order;

$order = Order::findOrFail($id);

$relation = $order->acmeNotes();
$note = $order->acmeNotes()->create(['body' => '配送日時を確認する']);
$notes = $order->acmeNotes;

$orders = Order::with('acmeNotes')->get();
$ordersWithNotes = Order::whereHas('acmeNotes')->get();
```

`create()`에서는 `hasMany`가 `order_id`를 설정하므로, 사용자가 전달한 배열에 외래 키를 섞을 필요가 없습니다. 역방향 관계는 자동으로 추가되지 않으므로 필요하면 별도로 정의합니다.

### 콜백에서는 조회까지 실행하지 않기

콜백의 반환값을 `get()`의 결과나 단일 모델로 하면, 프로퍼티 접근 시 등에서 유효한 관계로 다룰 수 없습니다. 관계의 구성과 데이터 조회를 분리합니다.

Eager 로딩의 `Builder::getRelation()`은 새 모델 인스턴스로 관계를 구성합니다. 따라서 콜백이 "조회된 주문 ID가 있음"을 전제로 하거나, 그 ID에 따라 다른 관계 정의를 반환하는 설계는 피합니다.

동적으로 등록해도 N+1 문제나 지연 로딩(Lazy Loading)의 제약은 사라지지 않습니다. 여러 모델을 다룰 때는 평소처럼 `with()` 등으로 관계를 로드합니다.

### belongsTo에서는 관계 이름의 추론에도 주의하기

`belongsTo()`는 관계 이름을 생략하면 호출한 곳을 백트레이스로 추론합니다. 클로저에서 정의할 때는 일반 모델 메서드와 같은 추론에 의존하지 말고, 외래 키·소유자 키와 함께 네 번째 인수인 관계 이름도 명시할 수 있습니다.

다음은 주문 측에 `customer_id`, 고객 측에 `id`가 있는 경우의 독립된 예시입니다.

```php theme={null}
use App\Models\Customer;
use App\Models\Order;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

Order::resolveRelationUsing(
    'acmeCustomer',
    static fn (Order $order): BelongsTo => $order->belongsTo(
        Customer::class,
        'customer_id',
        'id',
        'acmeCustomer',
    ),
);
```

## 이름 충돌과 상속 규칙 이해하기

`HasRelationships`는 **모델의 클래스 이름과 관계 이름**을 키로 클로저를 static 프로퍼티에 저장합니다. 존재하지 않는 메서드 호출을 받은 `Model::__call()`이 resolver를 검색하고, 대상 모델 인스턴스를 전달해 실행합니다.

```mermaid theme={null}
flowchart TD
    A["존재하지 않는 관계 메서드 호출"] --> B["Model::__call"]
    B --> C["자신의 클래스→부모 클래스 순으로<br>등록된 resolver를 검색"]
    C --> D["대상 모델을 전달해<br>클로저를 실행"]
    D --> E["Relation 인스턴스를 반환"]
```

그림은 resolver를 찾은 경우의 경로입니다. 기존 메서드나 속성과 충돌하는 경우에는 다른 경로가 된다는 점에 주의합니다.

| 상황 | 동작 |
| - | - |
| 같은 클래스·같은 관계 이름으로 다시 등록한다 | 나중의 클로저가 덮어쓴다 |
| 부모 모델에만 등록되어 있다 | 자식 모델도 부모의 resolver를 사용할 수 있다 |
| 자식 모델에도 같은 이름의 등록이 있다 | 자식의 등록이 우선한다. 부모의 등록은 변하지 않는다 |
| 상속 관계가 없는 모델에 같은 이름으로 등록한다 | 클래스별로 별개의 정의로 유지된다 |
| 같은 이름의 public 메서드가 모델이나 트레이트에 존재한다 | PHP가 기존 메서드를 호출한다. 동적 등록으로는 대체할 수 없다 |
| 같은 이름의 조회된 속성·캐스트·액세서가 있다 | 프로퍼티 접근에서는 속성 쪽이 먼저 평가된다 |

관계 이름에는 `acmeNotes`처럼 고유한 이름을 사용하고, 사용자의 메서드·속성·다른 패키지와의 충돌을 확인합니다. `method_exists()`만으로는 동적 등록의 존재를 확인할 수 없습니다. Eloquent의 `isRelation()`은 resolver도 확인하지만, 속성과의 충돌이 없다는 것까지는 보장하지 않습니다.

## 등록 상태와 조회 결과의 수명 구분하기

resolver의 등록과 각 모델이 보유하는 로드된 관계 데이터는 별개입니다.

| 작업 | 대상 |
| - | - |
| `resolveRelationUsing()` | 클래스 단위의 생성 방법을 등록·덮어쓴다 |
| `$order->unsetRelation('acmeNotes')` | 해당 모델의 로드된 결과를 제거한다. resolver는 남는다 |
| `Model::clearBootedModels()` | 모델의 boot 관련 상태 등을 지운다. resolver의 등록은 지우지 않는다 |

이미 `$order->acmeNotes`를 로드한 뒤에 resolver를 덮어써도, 해당 모델이 보유한 결과는 자동으로 갱신되지 않습니다. 다시 가져와야 한다면 관계 결과를 제거한 뒤 `load('acmeNotes')` 등을 사용합니다.

> [!WARNING]
>
> 등록 위치는 서비스 컨테이너가 아니라 static 프로퍼티입니다. 클로저에 요청·인증 사용자·테넌트 등을 캡처하면 장기 실행 프로세스에서 유지될 수 있습니다. 등록은 부팅 시의 안정적인 정의로 한정하고, 설정이나 코드를 변경할 때는 큐 워커나 Octane 등의 프로세스도 갱신합니다. [서비스의 라이프사이클 설계](https://kawax.biz/ko/advanced/package-service-lifetimes.md)도 참조하세요.

마찬가지로, Testbench에서 애플리케이션을 다시 만드는 것만으로 resolver가 사라진다고 생각하지 마세요. 활성·비활성을 비교하는 테스트에는 별도의 모델 fixture를 사용하거나 PHPUnit의 프로세스 격리를 사용해, 이전 등록에 대한 의존을 피합니다.

## 등록과 SQL을 모두 테스트하기

[Orchestra Testbench](https://kawax.biz/ko/advanced/package-testing.md)에서는 프로바이더의 등록뿐 아니라 외래 키와 실제 관계 조회를 검증합니다. 다음은 `tests/DynamicRelationsTest.php`의 예시입니다. 패키지의 클래스와 테스트 네임스페이스를 Composer로 로드할 수 있게 하고, PDO·SQLite 드라이버를 준비합니다.

```php theme={null}
<?php

namespace Acme\OrderNotes\Tests;

use Acme\OrderNotes\Models\Note;
use Acme\OrderNotes\OrderNotesServiceProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Orchestra\Testbench\TestCase;

final class DynamicRelationsTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [OrderNotesServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('acme-order-notes.order_model', OrderFixture::class);
        $app['config']->set('database.default', 'testing');
        $app['config']->set('database.connections.testing', [
            'driver' => 'sqlite',
            'database' => ':memory:',
            'prefix' => '',
        ]);
    }

    protected function setUp(): void
    {
        parent::setUp();

        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
        });

        Schema::create('acme_order_notes', function (Blueprint $table): void {
            $table->id();
            $table->unsignedBigInteger('order_id')->index();
            $table->text('body');
            $table->timestamps();
        });
    }

    public function test_provider_registers_relation_with_explicit_keys(): void
    {
        $order = new OrderFixture;
        $relation = $order->acmeNotes();

        $this->assertInstanceOf(HasMany::class, $relation);
        $this->assertInstanceOf(Note::class, $relation->getRelated());
        $this->assertSame('acme_order_notes.order_id', $relation->getQualifiedForeignKeyName());
        $this->assertSame('id', $relation->getLocalKeyName());
        $this->assertFalse($order->relationLoaded('acmeNotes'));
    }

    public function test_relation_supports_creation_eager_loading_and_existence_queries(): void
    {
        $order = OrderFixture::query()->create();
        OrderFixture::query()->create();

        $note = $order->acmeNotes()->create(['body' => '配送日時を確認する']);

        $this->assertSame($order->getKey(), $note->order_id);

        $orders = OrderFixture::with('acmeNotes')->orderBy('id')->get();

        $this->assertTrue($orders[0]->relationLoaded('acmeNotes'));
        $this->assertTrue($note->is($orders[0]->acmeNotes->sole()));
        $this->assertCount(0, $orders[1]->acmeNotes);
        $this->assertSame(
            [$order->getKey()],
            OrderFixture::whereHas('acmeNotes')->pluck('id')->all(),
        );
    }
}

final class OrderFixture extends Model
{
    protected $table = 'orders';

    public $timestamps = false;
}
```

테스트용 모델 이름은 `OrderFixture`이지만, 외래 키는 `order_fixture_id`가 아니라 `order_id` 그대로입니다. 클래스 이름으로부터의 추론에 의존하지 않는다는 것을 확인할 수 있습니다.

패키지의 CI에서는 다음 경계도 추가로 검증합니다.

* 등록되지 않은 별도의 fixture에서, `order_model`이 `null`이면 관계를 추가하지 않는 것.
* 자식 모델로의 상속과, 자식에만 같은 이름의 resolver를 등록한 경우의 우선순위.
* 기존 메서드나 속성과 충돌한 경우에 의도한 API를 사용할 수 있는 것.
* 관계를 한 번 로드한 모델과 새 모델에서 resolver를 다시 등록한 후의 차이.
* 지원하는 DB에서의 컬럼 타입·인덱스·외래 키 제약과, 사용자가 변경한 기본 키.

## 참조한 1차 정보

* [Laravel 공식 문서: Dynamic Relationships](https://github.com/laravel/docs/blob/10add0d2dade04cacfab436dcc3a8df7ca004e74/eloquent-relationships.md#dynamic-relationships)
* [Laravel 공식 문서: Package Development](https://github.com/laravel/docs/blob/10add0d2dade04cacfab436dcc3a8df7ca004e74/packages.md)
* [HasRelationships: 등록·상속 검색과 키 처리](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php)
* [Model: 동적 호출과 boot 상태의 삭제](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Model.php)
* [HasAttributes: 속성의 우선순위와 관계 결과의 보유](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php)
* [Builder: Eager 로딩 시의 관계 구성](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Builder.php)
* [QueriesRelationships: 관계의 존재 조건](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php)
* [HasOneOrMany: create 시의 외래 키 설정](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php)
* [Laravel 공식 테스트: DatabaseEloquentDynamicRelationsTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Database/DatabaseEloquentDynamicRelationsTest.php)


## Related topics

- [Eloquent 관계 입문](https://kawax.biz/ko/eloquent-relationships.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [패키지의 미들웨어 등록과 적용 범위](https://kawax.biz/ko/advanced/package-middleware.md)
- [패키지의 Artisan 명령어 등록과 의존성 해결](https://kawax.biz/ko/advanced/package-commands.md)
- [패키지의 Storage 드라이버 등록과 재해석](https://kawax.biz/ko/advanced/package-filesystem-drivers.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.