> ## 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/ko/eloquent-relationships.md), 이용자의 모델에 외부에서 관계를 추가하는 방법은 [패키지의 동적 관계 등록](https://kawax.biz/ko/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/ko/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()`는 매핑 테이블에서 처음 발견되는 별칭을 반환한다 |

패키지에서 두 번째 인수에 `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/ko/advanced/package-versioning.md)와 [패키지 마이그레이션의 배포와 업데이트](https://kawax.biz/ko/advanced/package-migrations.md)에도 업데이트 절차를 맞춥니다.

## 저장값과 양방향 조회를 테스트하기

[Orchestra Testbench](https://kawax.biz/ko/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를 준비하고, 마이그레이션 후 구 값이 없어지며 양방향 조회와 건수가 일치할 것.
* 큐 워커 등을 포함한 실제 업데이트 절차와, 되돌린 코드·데이터의 조합으로 복원할 수 있을 것.

## 참조한 1차 정보

* [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/ko/advanced/package-dynamic-relations.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [패키지의 커스텀 캐시 스토어와 계약](https://kawax.biz/ko/advanced/package-cache-drivers.md)
- [패키지의 버전 호환성 관리](https://kawax.biz/ko/advanced/package-versioning.md)
- [패키지의 커스텀 세션 드라이버](https://kawax.biz/ko/advanced/package-session-drivers.md)


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