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

# 包的 Morph map 与已存储数据的兼容性

> 解读 Laravel 13 的 Morph map，讲解包专属别名的注册、合并与冲突、严格模式的影响，以及现有数据的迁移与测试。

如果希望在移动或重命名包的模型后，仍能继续读取多态关联中已存储的数据，就要**将模型的类名与存入数据库的类型名分离**。Morph map 的别名与其说是简写，不如说是跨版本维护的存储格式契约。

本页基于 Laravel Framework `v13.42.0` 的官方实现。关联的基础请参阅 [Eloquent 关联入门](https://kawax.biz/zh-CN/eloquent-relationships.md)，从外部为使用方模型添加关联的方法请参阅[包的动态关联注册](https://kawax.biz/zh-CN/advanced/package-dynamic-relations.md)。

## 区分类型名与关联名

多态关联通过组合关联目标的主键与类型名来引用多种模型。例如，在评论的关联目标可以是文章或视频的结构中，不仅需要 `commentable_id`，还需要 `commentable_type`。

| 名称 | 本页示例 | 作用 |
| - | - | - |
| 关联方法 | `commentable()` | 从评论获取关联目标 |
| 存储类型的列 | `commentable_type` | 存储关联目标模型的种类 |
| Morph map 的键 | `acme-publishing.article` | 存入数据库的稳定别名 |
| Morph map 的值 | `Article::class` | 当前的关联目标模型类 |

未注册时，通常会使用 `Acme\Publishing\Models\Article` 这样的完全限定类名作为类型。注册别名后，在创建新关联等场景中会使用 `acme-publishing.article`。**仅靠注册并不会改写现有行中的值。**

```mermaid theme={null}
flowchart LR
    A["Article 模型"] -->|"getMorphClass()"| B["acme-publishing.article"]
    B --> C["存入 commentable_type"]
    C --> D["通过 morphTo 读取"]
    D -->|"通过 Morph map 解析"| A
```

Morph map 并不会创建关联方法或数据表。模型、列和索引照常准备即可。

## 只注册包专属的别名

示例中的 `Acme\Publishing\` 通过 Composer 的 PSR-4 对应到 `src/`。首先定义 `src/Models/Article.php`。

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

namespace Acme\Publishing\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Article extends Model
{
    protected $table = 'acme_articles';

    protected $fillable = ['title'];

    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}
```

在 `src/Models/Comment.php` 中定义反向关联。

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

namespace Acme\Publishing\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    protected $table = 'acme_comments';

    protected $fillable = ['body'];

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}
```

`acme_articles` 包含 `id`、`title` 和时间戳，`acme_comments` 包含 `id`、`body`、`commentable_type`、`commentable_id` 和时间戳。本例的主键为整数。可以使用 `$table->morphs('commentable')` 创建的列和复合索引，但在使用 UUID 等的结构中，请让列类型与关联目标保持一致。

接着，在普通的非延迟服务提供者 `src/PublishingServiceProvider.php` 的 `boot()` 中注册别名。

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

namespace Acme\Publishing;

use Acme\Publishing\Models\Article;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;

class PublishingServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Relation::morphMap([
            'acme-publishing.article' => Article::class,
        ]);
    }
}
```

服务提供者的加载通过[包自动发现](https://kawax.biz/zh-CN/advanced/package-discovery.md)进行配置。注册时不需要保存模型或更新数据库中的类型名。现有数据的迁移是另一项工作。

```php theme={null}
use Acme\Publishing\Models\Article;
use Illuminate\Database\Eloquent\Relations\Relation;

$article = Article::create(['title' => '包的公开契约']);
$comment = $article->comments()->create(['body' => '固定类型名']);

$alias = $article->getMorphClass();
$class = Relation::getMorphedModel('acme-publishing.article');
$parent = $comment->fresh()->commentable;
```

`$alias` 为 `acme-publishing.article`，`$class` 为 `Article::class`。只要 `Comment` 本身不会成为其他多态关联的关联目标，就无需为本例注册 `Comment` 的别名。

## 理解合并与名称冲突的规则

`Relation::morphMap()` 操作的是静态映射表。默认合并时，会用 **`$map + static::$morphMap`** 将新数组与现有数组合并。

| 操作或状态 | `v13.42.0` 中的行为 |
| - | - |
| 不带参数的 `morphMap()` | 获取当前映射表 |
| 注册未使用的别名 | 保留现有别名并追加 |
| 为同一别名注册不同的类 | 新传入的类优先 |
| `morphMap($map, false)` | 替换整个现有映射表 |
| `morphMap([])` | 属于合并，现有映射表不会被清空 |
| 同一个类有多个别名 | 标准的 `getMorphClass()` 返回在映射表中最先找到的别名 |

如果包在第二个参数中传入 `false`，就会清除其他包和使用方应用的注册。常规注册时应保留现有映射表，并选择能识别发布方和包的别名，而不是 `article` 这样的通用名称。不要将同一个别名复用为不同的含义。

只传入类名列表时，Laravel 会实例化每个模型并以表名作为键。如果包希望存储格式也不受表名变更影响，请像上面的示例那样**显式声明别名与类的对应关系**。

> [!WARNING]
>
> 如果使用方应用为同一个类添加了其他别名，注册顺序可能会改变此后存储的值以及关联查询的条件。“两个名称都注册了，所以兼容性得以保持”这一说法并不一定成立。即使允许替换类，也要将“已存储的数据能以同一别名读取”作为公开契约加以确认。

标准的 `getMorphClass()` 查找的是具体模型的类。即使使用 `Article` 的子类，也不会自动继承父类的别名。支持替换模型的包，需要设计成将实际使用的类注册到映射表中。

## 将严格模式作为整个应用的方针

官方文档中的 `enforceMorphMap()` 会一并完成别名注册和 `requireMorphMap(true)`。**这并不是仅限于包的强制。**

| API | 作用 |
| - | - |
| `morphMap([...])` | 注册映射表。不改变严格模式的当前值 |
| `requireMorphMap(true)` | 要求显式映射 |
| `enforceMorphMap([...])` | 启用该要求并注册映射表 |
| `getMorphedModel($alias)` | 直接查询映射表。未注册时返回 `null` |

在 `v13.42.0` 的标准实现中，严格模式会影响以下两个方向。

* **从模型到类型名**：`getMorphClass()` 会对未注册的模型抛出 `ClassMorphViolationException`。内置的通用 `Pivot` 类有例外处理，但普通模型和自定义 Pivot 没有同样的豁免。
* **从已存储的类型名到类**：`getActualClassNameForMorph()` 会对不在映射表键中的值抛出同样的异常。`morphTo()` 的常规获取和预加载也会用到它。即使类出现在映射表的“值”中，只要旧的完全限定类名不在“键”中，就无法加载。

严格模式关闭时，不在映射表中的类型名会被当作类名处理。但这并不能保证该类存在，也不能保证它是预期的关联目标。

包通常只用 `morphMap()` 注册自己的模型，是否在整个应用中强制由使用方决定。如果使用方已经启用了强制，不要在包中将其改回 `requireMorphMap(false)`。

## 先迁移现有数据再判断兼容性

引入别名注册时，新安装与现有应用升级的条件不同。官方文档也提醒，引入时需要将现有的完全限定类名转换为别名。

### 只确认反向获取是不够的

严格模式关闭时，只要旧 `commentable_type` 中的类仍然存在，有时仍能从评论通过 `morphTo()` 获取文章。但文章一侧的 `morphMany()` 会按 `getMorphClass()` 的值筛选类型。注册别名后它会查找 `acme-publishing.article`，因此**使用旧类名的行会从文章的评论列表中消失**。

`MorphOneOrMany` 在创建时保存类型名，并将其同时用于查询条件和新建记录。即使新旧类型名混杂，它也不会自动以 OR 检索所有值。

### 将升级步骤纳入发布

下面是将旧类名转换为别名的代码片段。请将其实现为目标应用的数据迁移，不要从服务提供者中自动执行。

```php theme={null}
use Illuminate\Support\Facades\DB;

DB::table('acme_comments')
    ->where('commentable_type', 'Acme\\Publishing\\Models\\Article')
    ->update(['commentable_type' => 'acme-publishing.article']);
```

仅靠这一步并不能完成部署。

1. 调查目标 `*_type` 列及其存储值，在更新前准备好备份、记录数确认和恢复步骤。如果其他表也引用了同一模型，也要一并纳入。
2. 首次引入时，要确定包含停止写入或维护在内的切换步骤，避免写入旧类名的进程与写入别名的进程同时存在。对于大表，还要考虑更新锁和分批处理。
3. 切换映射表和数据，在不改变主键值的前提下转换类型名。迁移代码中的旧类名应固定为**过去实际存储的字符串**，而不是会因将来重命名而改变含义的 `Article::class`。
4. 确认旧值的残留数量、新的保存、双向关联获取以及预加载，在更新长时间运行的 worker 之后再恢复写入。
5. 回滚时，要确认回退后的代码能否解析已存储的别名。仅回退 Composer 版本，并不能恢复数据库中的类型名。

如果保持已固定的别名，只修改模型的命名空间，那么将映射表的值改为新类即可继续使用原有的类型名。另一方面，修改别名本身应视为伴随数据迁移的变更。升级步骤也要与[包的版本兼容性管理](https://kawax.biz/zh-CN/advanced/package-versioning.md)和[包迁移的发布与更新](https://kawax.biz/zh-CN/advanced/package-migrations.md)保持一致。

## 测试存储值与双向获取

在 [Orchestra Testbench](https://kawax.biz/zh-CN/advanced/package-testing.md) 中，不仅要验证映射表，还要验证存入数据库的值和双向获取。以下示例假设通过 Composer 自动加载包，并已准备好 PDO 和 SQLite 驱动。服务提供者在测试中显式注册，与自动发现的验证分开。

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

namespace Acme\Publishing\Tests;

use Acme\Publishing\Models\Article;
use Acme\Publishing\PublishingServiceProvider;
use Illuminate\Database\ClassMorphViolationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Orchestra\Testbench\TestCase;

final class MorphMapTest extends TestCase
{
    private array $originalMap;

    private bool $originalRequirement;

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

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

        $this->originalMap = Relation::morphMap();
        $this->originalRequirement = Relation::requiresMorphMap();

        Relation::morphMap(['app.record' => UnrelatedRecord::class], false);
        Relation::requireMorphMap(false);

        $this->app->register(PublishingServiceProvider::class);

        Schema::create('acme_articles', function (Blueprint $table): void {
            $table->id();
            $table->string('title');
            $table->timestamps();
        });

        Schema::create('acme_comments', function (Blueprint $table): void {
            $table->id();
            $table->text('body');
            $table->morphs('commentable');
            $table->timestamps();
        });
    }

    protected function tearDown(): void
    {
        try {
            parent::tearDown();
        } finally {
            Relation::morphMap($this->originalMap, false);
            Relation::requireMorphMap($this->originalRequirement);
        }
    }

    public function test_registration_preserves_other_mappings_and_strictness(): void
    {
        $this->assertSame(UnrelatedRecord::class, Relation::getMorphedModel('app.record'));
        $this->assertSame('acme-publishing.article', (new Article)->getMorphClass());
        $this->assertFalse(Relation::requiresMorphMap());
    }

    public function test_alias_is_stored_and_both_directions_can_be_loaded(): void
    {
        $article = Article::create(['title' => '公开契约']);
        $comment = $article->comments()->create(['body' => '以别名存储']);

        $this->assertSame(
            'acme-publishing.article',
            DB::table('acme_comments')->where('id', $comment->getKey())->value('commentable_type'),
        );
        $this->assertTrue($comment->fresh()->commentable->is($article));
        $this->assertTrue(Article::with('comments')->findOrFail($article->getKey())->comments->sole()->is($comment));
    }

    public function test_strict_read_does_not_accept_an_unmapped_raw_class_name(): void
    {
        Relation::requireMorphMap();

        $this->expectException(ClassMorphViolationException::class);

        Model::getActualClassNameForMorph(Article::class);
    }
}

class UnrelatedRecord extends Model
{
}
```

映射表和强制标志是相互独立的静态状态，因此不要认为仅重新创建应用就会将其初始化。本例对两者都进行了保存和恢复。在生产环境中也不要按请求或租户改写它们，而应仅限于启动时的稳定注册。已创建的关联会保存类型名，因此中途更改注册也不会统一反映到现有实例上。

还要在包的 CI 中添加以下回归用例。

* 在使用方应用已启用严格模式的状态下注册服务提供者，不会将其关闭。
* 在同一别名冲突、同一个类有多个别名、替换了具体模型的情况下，已公开的存储格式保持不变。
* 准备以升级前类名存储的 fixture，迁移后旧值消失，双向获取与记录数一致。
* 包含队列 worker 等在内的实际升级步骤，能够通过回退后的代码与数据组合进行恢复。

## 一手资料

* [Laravel 官方文档：Custom Polymorphic Types](https://github.com/laravel/docs/blob/13.x/eloquent-relationships.md#custom-polymorphic-types)
* [Laravel 官方文档：Package Development](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Relation：映射表的合并与严格模式](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/Relation.php)
* [HasRelationships：类型名的获取与类的解析](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php)
* [MorphOneOrMany：类型名的保存、查询条件与存储](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php)
* [MorphTo：预加载时的类型解析](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/MorphTo.php)
* [Laravel 官方测试：Morph map 的注册与解析](https://github.com/laravel/framework/blob/v13.42.0/tests/Database/DatabaseEloquentRelationTest.php)
* [Laravel 官方测试：严格模式下的读写](https://github.com/laravel/framework/blob/v13.42.0/tests/Database/DatabaseEloquentStrictMorphsTest.php)


## Related topics

- [包的动态关联注册](https://kawax.biz/zh-CN/advanced/package-dynamic-relations.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [包的自定义缓存存储与契约](https://kawax.biz/zh-CN/advanced/package-cache-drivers.md)
- [包的版本兼容性管理](https://kawax.biz/zh-CN/advanced/package-versioning.md)
- [包的自定义 Session 驱动](https://kawax.biz/zh-CN/advanced/package-session-drivers.md)


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