> ## 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，說明套件專屬別名的註冊、合併與衝突、嚴格模式的影響，以及既有資料的遷移與測試。

若希望在移動或重新命名套件的 model 之後，仍能持續讀取多型關聯中已儲存的資料，就要**將 model 的類別名稱與存入資料庫的型別名稱分離**。Morph map 的別名與其說是簡寫，不如說是跨版本維護的儲存格式契約。

本頁依據 Laravel Framework `v13.42.0` 的官方實作。關聯的基礎請參閱 [Eloquent 關聯入門](https://kawax.biz/zh-TW/eloquent-relationships.md)，從外部為使用端 model 新增關聯的方法請參閱[套件的動態關聯註冊](https://kawax.biz/zh-TW/advanced/package-dynamic-relations.md)。

## 區分型別名稱與關聯名稱

多型關聯透過組合關聯目標的主鍵與型別名稱來參照多種 model。例如，在評論的關聯目標可以是文章或影片的架構中，不只需要 `commentable_id`，也需要 `commentable_type`。

| 名稱 | 本頁範例 | 作用 |
| - | - | - |
| 關聯方法 | `commentable()` | 從評論取得關聯目標 |
| 儲存型別的欄位 | `commentable_type` | 儲存關聯目標 model 的種類 |
| Morph map 的鍵 | `acme-publishing.article` | 存入資料庫的穩定別名 |
| Morph map 的值 | `Article::class` | 目前的關聯目標 model 類別 |

未註冊時，通常會使用 `Acme\Publishing\Models\Article` 這類完整限定類別名稱作為型別。註冊別名後，在建立新關聯等情境中會使用 `acme-publishing.article`。**僅靠註冊並不會改寫既有資料列的值。**

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

Morph map 並不會建立關聯方法或資料表。model、欄位與索引照常準備即可。

## 只註冊套件專屬的別名

範例中的 `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 等的架構中，請讓欄位型別與關聯目標一致。

接著，在一般的非延遲 service provider `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,
        ]);
    }
}
```

Provider 的載入透過[套件自動偵測](https://kawax.biz/zh-TW/advanced/package-discovery.md)設定。註冊時不需要儲存 model 或更新資料庫中的型別名稱。既有資料的遷移是另一項工作。

```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 會建立每個 model 的實例並以資料表名稱作為鍵。若套件希望儲存格式也不受資料表名稱變更影響，請像上方範例一樣**明確宣告別名與類別的對應**。

> [!WARNING]
>
> 若使用端應用程式為同一類別新增其他別名，註冊順序可能改變之後儲存的值與關聯查詢的條件。「兩個名稱都註冊了，所以相容性得以維持」並不一定成立。即使允許替換類別，也要將「已儲存的資料能以同一別名讀取」作為公開契約加以確認。

標準的 `getMorphClass()` 會尋找具體 model 的類別。即使使用 `Article` 的子類別，也不會自動繼承父類別的別名。支援替換 model 的套件，需要設計成將實際使用的類別註冊到對應表中。

## 將嚴格模式作為整個應用程式的方針

官方文件中的 `enforceMorphMap()` 會一併完成別名註冊與 `requireMorphMap(true)`。**這並不是僅限於套件的強制。**

| API | 作用 |
| - | - |
| `morphMap([...])` | 註冊對應表。不改變嚴格模式的目前值 |
| `requireMorphMap(true)` | 要求明確的對應 |
| `enforceMorphMap([...])` | 啟用該要求並註冊對應表 |
| `getMorphedModel($alias)` | 直接查詢對應表。未註冊時回傳 `null` |

在 `v13.42.0` 的標準實作中，嚴格模式會影響以下兩個方向。

* **從 model 到型別名稱**：`getMorphClass()` 會對未註冊的 model 拋出 `ClassMorphViolationException`。內建的通用 `Pivot` 類別有例外處理，但一般 model 與自訂 Pivot 沒有同樣的豁免。
* **從已儲存的型別名稱到類別**：`getActualClassNameForMorph()` 會對不在對應表鍵中的值拋出同樣的例外。`morphTo()` 的一般取得與 Eager 載入也會用到它。即使類別出現在對應表的「值」中，只要舊的完整限定類別名稱不在「鍵」中，就無法載入。

嚴格模式停用時，不在對應表中的型別名稱會被當作類別名稱處理。但這並不保證該類別存在，也不保證它是預期的關聯目標。

套件通常只以 `morphMap()` 註冊自己的 model，是否在整個應用程式中強制則交由使用端決定。若使用端已經啟用強制，請不要從套件將其改回 `requireMorphMap(false)`。

## 先遷移既有資料再判斷相容性

引入別名註冊時，新安裝與既有應用程式升級的條件不同。官方文件也提醒，引入時需要將既有的完整限定類別名稱轉換為別名。

### 只確認反向取得是不夠的

嚴格模式停用時，只要舊 `commentable_type` 的類別仍然存在，有時仍可從評論以 `morphTo()` 取得文章。但文章端的 `morphMany()` 會依 `getMorphClass()` 的值篩選型別。註冊別名後它會尋找 `acme-publishing.article`，因此**使用舊類別名稱的資料列會從文章的評論清單中消失**。

`MorphOneOrMany` 在建立時保存型別名稱，並同時用於查詢條件與新建資料。即使新舊型別名稱混雜，也不會自動以 OR 搜尋所有值。

### 將升級步驟納入發布

以下是將舊類別名稱轉換為別名的程式片段。請將其實作為目標應用程式的資料遷移，不要從 service provider 自動執行。

```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` 欄位與儲存值，在更新前準備好備份、筆數確認與還原步驟。若其他資料表也參照同一 model，也要一併納入。
2. 首次引入時，要決定包含停止寫入或維護在內的切換步驟，避免寫入舊類別名稱的程序與寫入別名的程序同時存在。對於大型資料表，也要考慮更新鎖定與批次處理。
3. 切換對應表與資料，在不改變主鍵值的前提下轉換型別名稱。遷移程式中的舊類別名稱應固定為**過去實際儲存的字串**，而不是會因未來重新命名而改變意義的 `Article::class`。
4. 確認舊值的殘留筆數、新的儲存、雙向關聯取得與 Eager 載入，並在更新長時間執行的 worker 後再恢復寫入。
5. 回滾時，要確認回退後的程式能否解析已儲存的別名。僅回退 Composer 版本，並無法還原資料庫中的型別名稱。

若維持已固定的別名，只變更 model 的命名空間，將對應表的值改為新類別即可沿用原本的型別名稱。另一方面，變更別名本身應視為伴隨資料遷移的變更。升級步驟也要與[套件的版本相容性管理](https://kawax.biz/zh-TW/advanced/package-versioning.md)及[套件 Migration 的公開與更新](https://kawax.biz/zh-TW/advanced/package-migrations.md)保持一致。

## 測試儲存值與雙向取得

在 [Orchestra Testbench](https://kawax.biz/zh-TW/advanced/package-testing.md) 中，不只驗證對應表，也要驗證存入資料庫的值與雙向取得。以下範例假設透過 Composer 自動載入套件，並已備妥 PDO 與 SQLite 驅動程式。Provider 在測試中明確註冊，與自動偵測的驗證分開。

```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 中加入以下回歸案例。

* 在使用端應用程式已啟用嚴格模式的狀態下註冊 provider，不會將其停用。
* 在同一別名衝突、同一類別有多個別名、替換了具體 model 的情況下，已公開的儲存格式維持不變。
* 準備以升級前類別名稱儲存的 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：Eager 載入時的型別解析](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-TW/advanced/package-dynamic-relations.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [套件的自訂快取儲存與契約](https://kawax.biz/zh-TW/advanced/package-cache-drivers.md)
- [套件的自訂 Session 驅動](https://kawax.biz/zh-TW/advanced/package-session-drivers.md)
- [套件的版本相容性管理](https://kawax.biz/zh-TW/advanced/package-versioning.md)


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