> ## 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()` 注册关联的创建方式。无需直接改写模型文件，就能像普通关联一样进行获取、预加载以及按存在条件查询。

本页面基于对 Laravel Framework `v13.40.0` 官方源码的确认。关联的基础请参阅[Eloquent 关联入门](https://kawax.biz/zh-CN/eloquent-relationships.md)，服务提供者的加载请参阅[包的自动发现](https://kawax.biz/zh-CN/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/zh-CN/advanced/package-migrations.md)是不同的处理。下面的服务提供者中也不包含数据库访问或迁移执行。

### 在 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-CN/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()` 的结果或单个模型，那么在属性访问等时候就无法作为有效的关联处理。请将关联的构建与数据获取分离。

预加载时的 `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 会调用已有方法。动态注册无法替换它 |
| 存在同名的已获取属性、类型转换或访问器 | 属性访问时会先评估属性一侧 |

关联名请使用像 `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-CN/advanced/package-service-lifetimes.md)。

同样，不要认为仅在 Testbench 中重新创建应用就能让 resolver 消失。比较启用与禁用状态的测试，请使用不同的模型 fixture，或使用 PHPUnit 的进程隔离，避免依赖之前的注册。

## 同时测试注册与 SQL

在 [Orchestra Testbench](https://kawax.biz/zh-CN/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 重新注册后的差异。
* 所支持数据库中的列类型、索引、外键约束，以及使用者修改过的主键。

## 一手资料

* [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：预加载时的关联构建](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-CN/eloquent-relationships.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [包的静态分析（PHPStan / Larastan）](https://kawax.biz/zh-CN/advanced/package-static-analysis.md)
- [包的路由注册与缓存](https://kawax.biz/zh-CN/advanced/package-routes.md)
- [Laravel MCP](https://kawax.biz/zh-CN/mcp.md)


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