> ## 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/jp/eloquent-relationships.md)、プロバイダーの読み込みは[パッケージの自動検出](https://kawax.biz/jp/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/jp/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/jp/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()` は関連名を省略すると呼び出し元をバックトレースで推測します。クロージャからの定義では通常のモデルメソッドと同じ推測に頼らず、外部キー・所有者キーに加えて第4引数の関連名も明示できます。

次は、注文側に `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/jp/advanced/package-service-lifetimes.md)も参照してください。

同様に、Testbenchでアプリケーションを作り直すだけでresolverが消えるとは考えません。有効・無効を比較するテストには別のモデルfixtureを使うか、PHPUnitのプロセス分離を使い、前の登録への依存を避けます。

## 登録とSQLの両方をテストする

[Orchestra Testbench](https://kawax.biz/jp/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での列型・インデックス・外部キー制約と、利用者が変更した主キー。

## 参照した一次情報

* [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/jp/eloquent-relationships.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [パッケージのマイグレーション公開と更新](https://kawax.biz/jp/advanced/package-migrations.md)
- [パッケージの静的解析（PHPStan / Larastan）](https://kawax.biz/jp/advanced/package-static-analysis.md)
- [Laravelパッケージ開発](https://kawax.biz/jp/advanced/package-development.md)


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