> ## 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 Loading 以及依存在條件查詢。

本頁確認了 Laravel Framework `v13.40.0` 的官方原始碼。關聯的基本說明請參閱 [Eloquent 關聯入門](https://kawax.biz/zh-TW/eloquent-relationships.md)，提供者的載入請參閱[套件自動探索](https://kawax.biz/zh-TW/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]
>
> 註冊動態關聯並不會建立資料表或外鍵約束。這與[套件 Migration 的發布與更新](https://kawax.biz/zh-TW/advanced/package-migrations.md)是不同的處理。下列提供者中也不包含 DB 存取或 Migration 的執行。

### 在 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/zh-TW/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 Loading 的 `Builder::getRelation()` 會以新的模型實例建構關聯。因此，請避免讓回呼以「已有取得的訂單 ID」為前提，或依該 ID 回傳不同關聯定義的設計。

即使是動態註冊，N+1 問題與 Lazy Loading 的限制也不會消失。處理多個模型時，請照常使用 `with()` 等方式載入關聯。

### 使用 belongsTo 時也要注意關聯名稱的推測

`belongsTo()` 在省略關聯名稱時，會透過 backtrace 推測呼叫端。從閉包定義時，不應依賴與一般模型方法相同的推測，除了外鍵與擁有者鍵之外，也可以在第 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 |
| 子模型也有同名的註冊 | 子模型的註冊優先。父模型的註冊不變 |
| 在沒有繼承關係的模型以同名註冊 | 依類別分別保留為不同的定義 |
| 模型或 Trait 中存在同名的 public 方法 | PHP 會呼叫既有方法。無法以動態註冊取代 |
| 存在同名的已取得屬性、Cast 或存取器 | 存取屬性時會先評估屬性端 |

關聯名稱請使用 `acmeNotes` 這類專屬名稱，並確認不會與使用者的方法、屬性或其他套件衝突。僅用 `method_exists()` 無法確認是否有動態註冊。Eloquent 的 `isRelation()` 也會確認 resolver，但無法保證沒有與屬性衝突。

## 區分註冊狀態與取得結果的生命週期

resolver 的註冊與各模型保留的已載入關聯資料是不同的東西。

| 操作 | 對象 |
| - | - |
| `resolveRelationUsing()` | 註冊或覆寫以類別為單位的建立方式 |
| `$order->unsetRelation('acmeNotes')` | 移除該模型已載入的結果。resolver 仍會保留 |
| `Model::clearBootedModels()` | 清除模型的 boot 相關狀態等。不會清除 resolver 的註冊 |

即使在已載入 `$order->acmeNotes` 之後覆寫 resolver，該模型保留的結果也不會自動更新。若需要重新取得，請先移除關聯結果，再使用 `load('acmeNotes')` 等方式。

> [!WARNING]
>
> 註冊位置不是服務容器，而是 static 屬性。若在閉包中擷取請求、已驗證使用者或租戶等，可能會在長時間執行的處理程序中被保留。請將註冊限定為啟動時穩定的定義，並在設定或程式碼變更時，一併更新佇列 Worker 或 Octane 等處理程序。另請參閱[服務的生命週期設計](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)。

同樣地，不要以為在 Testbench 中重新建立應用程式就會讓 resolver 消失。比較啟用與停用的測試請使用不同的模型 fixture，或使用 PHPUnit 的處理程序隔離，以避免依賴先前的註冊。

## 同時測試註冊與 SQL

在 [Orchestra Testbench](https://kawax.biz/zh-TW/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_id`，而不是 `order_fixture_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 Loading 時的關聯建構](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/zh-TW/eloquent-relationships.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [套件的靜態分析（PHPStan / Larastan）](https://kawax.biz/zh-TW/advanced/package-static-analysis.md)
- [套件的 Storage 驅動程式註冊與重新解析](https://kawax.biz/zh-TW/advanced/package-filesystem-drivers.md)
- [套件的路由註冊與快取](https://kawax.biz/zh-TW/advanced/package-routes.md)


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