> ## 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を読み解き、パッケージ固有の別名登録、マージと競合、厳格モードの影響、既存データの移行とテストを解説します。

パッケージのモデルを移動・改名しても多態リレーションの保存データを読み続けたい場合は、**モデルのクラス名とDBに保存する型名を分離**します。Morph mapの別名は短縮表記というより、リリースをまたいで維持する保存形式の契約です。

このページはLaravel Framework `v13.42.0` の公式実装を確認しています。リレーションの基本は[Eloquentリレーション入門](https://kawax.biz/jp/eloquent-relationships.md)、利用者のモデルに外から関連を追加する方法は[動的リレーション登録](https://kawax.biz/jp/advanced/package-dynamic-relations.md)を参照してください。

## 型名と関連名を分ける

多態リレーションは、関連先の主キーと型名を組み合わせて、複数種類のモデルを参照します。たとえばコメントの関連先を記事や動画にできる構成では、`commentable_id` だけでなく `commentable_type` も必要です。

| 名前 | このページの例 | 役割 |
| - | - | - |
| 関連メソッド | `commentable()` | コメントから関連先を取得する |
| 型を保存する列 | `commentable_type` | 関連先モデルの種類を保存する |
| Morph mapのキー | `acme-publishing.article` | DBに保存する安定した別名 |
| 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/jp/advanced/package-discovery.md)で設定します。登録中にモデルを保存したり、DBの型名を更新したりする必要はありません。既存データの移行は別の作業です。

```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()` はstaticの対応表を操作します。デフォルトのマージでは、新しい配列と既存の配列を **`$map + static::$morphMap`** で結合します。

| 操作・状態 | `v13.42.0` の挙動 |
| - | - |
| 引数なしの `morphMap()` | 現在の対応表を取得する |
| 未使用の別名を登録する | 既存の別名を残して追加する |
| 同じ別名に別クラスを登録する | 新しく渡したクラスが優先される |
| `morphMap($map, false)` | 既存の対応表全体を置き換える |
| `morphMap([])` | マージなので、既存の対応表は消えない |
| 同じクラスに複数の別名がある | 標準の `getMorphClass()` は対応表で最初に見つかる別名を返す |

パッケージから第2引数に `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()` の通常取得とEagerロードでも使われます。対応表の「値」にクラスがあっても、古い完全修飾クラス名が「キー」にないなら読み込めません。

厳格モードが無効なら、対応表にない型名はクラス名として扱われます。ただし、そのクラスが存在することや、期待する関連先であることまで保証するわけではありません。

パッケージ側では通常 `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. 旧値の残存件数、新規保存、両方向の関連取得、Eagerロードを確認し、長寿命ワーカーを更新してから書き込みを再開します。
5. ロールバックする場合は、戻すコードが保存済みの別名を解決できるか確認します。Composerのバージョンを戻すだけでは、DBの型名は戻りません。

すでに固定した別名を維持してモデルの名前空間だけを変える場合は、対応表の値を新クラスへ変更することで、型名をそのまま利用できます。一方、別名そのものの変更はデータ移行を伴う変更として扱います。[バージョン互換性管理](https://kawax.biz/jp/advanced/package-versioning.md)と[マイグレーションの公開・更新](https://kawax.biz/jp/advanced/package-migrations.md)にも更新手順を揃えます。

## 保存値と両方向の取得をテストする

[Orchestra Testbench](https://kawax.biz/jp/advanced/package-testing.md)では、対応表だけでなくDBに保存した値と両方向の取得を検証します。次の例は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
{
}
```

対応表と強制フラグは別々のstatic状態なので、アプリケーションを作り直すだけで初期化されるとは考えません。この例は両方を保存・復元します。本番でもリクエストやテナントごとに書き換えず、起動時の安定した登録に限定します。すでに生成したリレーションは型名を保持するため、途中の登録変更が既存インスタンスへ一律に反映されるわけでもありません。

パッケージのCIには、さらに次の回帰ケースを追加します。

* 利用アプリがすでに厳格モードを有効にしている状態で、プロバイダーを登録しても無効化しないこと。
* 同じ別名の競合、同じクラスへの複数の別名、差し替えた具象モデルで、公開した保存形式が変わらないこと。
* 更新前のクラス名で保存したfixtureを用意し、移行後に旧値がなくなり、両方向の取得と件数が一致すること。
* キューワーカーなどを含む実際の更新手順と、戻すコード・データの組み合わせで復元できること。

## 参照した一次情報

* [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/jp/advanced/package-dynamic-relations.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [パッケージのカスタムキャッシュストアと契約](https://kawax.biz/jp/advanced/package-cache-drivers.md)
- [パッケージのバージョン互換性管理](https://kawax.biz/jp/advanced/package-versioning.md)
- [パッケージのカスタムセッションドライバー](https://kawax.biz/jp/advanced/package-session-drivers.md)


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