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

# Package morph maps and stored data compatibility

> Reads Laravel 13 morph maps and explains package-specific alias registration, merging and conflicts, the impact of strict mode, and migrating and testing existing data.

To keep reading the stored data of polymorphic relations after you move or rename a package model, **separate the model class name from the type name stored in the database**. A morph map alias is less a shorthand than a storage-format contract you maintain across releases.

This page checks the official implementation of Laravel Framework `v13.42.0`. For relationship basics, see [Introduction to Eloquent relationships](https://kawax.biz/en/eloquent-relationships.md). To add relations to the consuming app's models from outside, see [Registering dynamic relationships](https://kawax.biz/en/advanced/package-dynamic-relations.md).

## Separate type names from relation names

A polymorphic relation combines the related primary key and a type name to reference several kinds of models. For example, when comments can belong to articles or videos, you need `commentable_type` in addition to `commentable_id`.

| Name | Example on this page | Role |
| - | - | - |
| Relation method | `commentable()` | Retrieves the related model from a comment |
| Column storing the type | `commentable_type` | Stores the kind of related model |
| Morph map key | `acme-publishing.article` | Stable alias stored in the database |
| Morph map value | `Article::class` | Current related model class |

Without registration, a fully qualified class name such as `Acme\Publishing\Models\Article` is normally used as the type. Once you register an alias, `acme-publishing.article` is used, for example when creating new relations. **Registering it does not rewrite the values in existing rows.**

```mermaid theme={null}
flowchart LR
    A["Article model"] -->|"getMorphClass()"| B["acme-publishing.article"]
    B --> C["Save to commentable_type"]
    C --> D["Load with morphTo"]
    D -->|"Resolve with morph map"| A
```

A morph map does not create relation methods or tables. Prepare models, columns and indexes as usual.

## Register only package-specific aliases

In this example, `Acme\Publishing\` maps to `src/` through Composer PSR-4. First, define `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` defines the inverse relation.

```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` has `id`, `title` and timestamps. `acme_comments` has `id`, `body`, `commentable_type`, `commentable_id` and timestamps. Primary keys in this example are integers. You can use the columns and composite index created by `$table->morphs('commentable')`, but with UUIDs or similar setups, match the column types to the related model.

Next, register the alias in `boot()` of a regular, non-deferred service provider, `src/PublishingServiceProvider.php`.

```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,
        ]);
    }
}
```

Configure provider loading with [package auto-discovery](https://kawax.biz/en/advanced/package-discovery.md). You don't need to save models or update type names in the database during registration. Migrating existing data is a separate task.

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

$article = Article::create(['title' => 'Public contract of the package']);
$comment = $article->comments()->create(['body' => 'Pin the type name']);

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

`$alias` is `acme-publishing.article` and `$class` is `Article::class`. Unless `Comment` itself becomes the related model of another polymorphic relation, you don't need to register an alias for `Comment` for this example.

## Understand merging and name collision rules

`Relation::morphMap()` operates on a static map. The default merge combines the new array and the existing array with **`$map + static::$morphMap`**.

| Operation or state | Behavior in `v13.42.0` |
| - | - |
| `morphMap()` without arguments | Returns the current map |
| Register an unused alias | Adds it and keeps existing aliases |
| Register a different class for the same alias | The newly passed class wins |
| `morphMap($map, false)` | Replaces the entire existing map |
| `morphMap([])` | Merges, so the existing map is not cleared |
| Several aliases for the same class | The default `getMorphClass()` returns the first alias found in the map |

Passing `false` as the second argument from a package erases registrations made by other packages and the consuming app. For normal registration, keep the existing map and choose an alias that identifies the vendor and package rather than a generic name such as `article`. Don't reuse the same alias for a different meaning.

When you pass only a list of class names, Laravel instantiates each model and uses its table name as the key. Packages that want to decouple the storage format from table name changes too should **declare the alias-to-class mapping explicitly**, as in the example above.

> [!WARNING]
>
> If the consuming app adds another alias for the same class, the registration order can change the values stored from then on and the conditions of relation queries. Registering both names does not necessarily keep compatibility. Even when you allow replacing the class, verify as part of the public contract that stored data can be read with the same alias.

The default `getMorphClass()` looks up the concrete model class. Using a subclass of `Article` does not automatically inherit the parent class's alias. Packages that support replacing models need a design that registers the classes actually in use in the map.

## Make strict mode an app-wide policy

`enforceMorphMap()` in the official documentation registers aliases and calls `requireMorphMap(true)` in one step. **It is not enforcement limited to the package.**

| API | Role |
| - | - |
| `morphMap([...])` | Registers the map. Does not change the current strict mode setting |
| `requireMorphMap(true)` | Requires explicit mappings |
| `enforceMorphMap([...])` | Enables the requirement and registers the map |
| `getMorphedModel($alias)` | Looks up the map directly. Returns `null` when not registered |

In the default implementation of `v13.42.0`, strict mode affects both directions.

* **From model to type name**: `getMorphClass()` throws `ClassMorphViolationException` for unregistered models. The built-in generic `Pivot` class is exempt, but regular models and custom pivots get no such exemption.
* **From stored type name to class**: `getActualClassNameForMorph()` throws the same exception for values that are not keys in the map. It is also used by regular `morphTo()` retrieval and eager loading. Even if the class appears as a "value" in the map, an old fully qualified class name that is not a "key" cannot be loaded.

When strict mode is disabled, type names not in the map are treated as class names. That does not guarantee the class exists or that it is the expected related model.

Packages usually register only their own models with `morphMap()` and leave app-wide enforcement to the consuming app. If the consuming app already enforces it, don't switch it back with `requireMorphMap(false)` from the package.

## Migrate existing data before judging compatibility

Introducing alias registration has different conditions for new installs and for upgrading existing apps. The official documentation also notes that when you introduce it, you need to convert existing fully qualified class names to aliases.

### Checking the inverse lookup alone is not enough

When strict mode is disabled, you may still be able to retrieve the article from a comment with `morphTo()` as long as the class in the old `commentable_type` exists. However, `morphMany()` on the article side filters by the value of `getMorphClass()`. After the alias is registered it looks for `acme-publishing.article`, so **rows with the old class name drop out of the article's comment list**.

`MorphOneOrMany` keeps the type name when it is constructed and uses it for both query conditions and creating new records. Even when old and new type names are mixed, it does not automatically search all of them with OR.

### Include the upgrade procedure in the release

The following is a fragment that converts the old class name to the alias. Implement it as a data migration for the target app, and don't run it automatically from a 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']);
```

This step alone does not complete the deployment.

1. Survey the target `*_type` columns and stored values, and prepare a backup, row counts and a restore procedure before updating. If other tables reference the same model, include them too.
2. For the first rollout, decide on a cutover procedure, including stopping writes or maintenance, so that processes writing the old class name and processes writing the alias do not run at the same time. For large tables, also consider update locks and batching.
3. Switch the map and the data, converting type names without changing primary key values. Pin the old class name in the migration code as **the string that was actually stored in the past**, not `Article::class`, whose meaning changes with future renames.
4. Check the remaining count of old values, new saves, relation lookups in both directions and eager loading, then update long-running workers before resuming writes.
5. To roll back, check that the code you roll back to can resolve the stored alias. Reverting the Composer version alone does not revert type names in the database.

If you keep an alias you already pinned and only change the model namespace, change the map value to the new class and the type names keep working as they are. Changing the alias itself, on the other hand, should be treated as a change that requires a data migration. Align the upgrade procedure with [Version compatibility management](https://kawax.biz/en/advanced/package-versioning.md) and [Publishing and updating migrations](https://kawax.biz/en/advanced/package-migrations.md).

## Test stored values and lookups in both directions

With [Orchestra Testbench](https://kawax.biz/en/advanced/package-testing.md), verify not only the map but also the values stored in the database and lookups in both directions. The following example assumes the package is autoloaded through Composer and the PDO SQLite driver is available. It registers the provider explicitly in the test, keeping it separate from verifying auto-discovery.

```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' => 'Public contract']);
        $comment = $article->comments()->create(['body' => 'Store with the alias']);

        $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
{
}
```

The map and the enforcement flag are separate static state, so don't assume rebuilding the application resets them. This example saves and restores both. In production too, don't rewrite them per request or per tenant, and limit registration to stable registration at boot. Relations that were already constructed keep their type name, so changing registration midway is not uniformly reflected in existing instances either.

Add the following regression cases to the package CI as well.

* Registering the provider does not disable strict mode when the consuming app has already enabled it.
* The published storage format stays the same with conflicting aliases, several aliases for the same class, and a replaced concrete model.
* Prepare fixtures stored with the pre-upgrade class name, and after migration no old values remain and lookups in both directions and counts match.
* The actual upgrade procedure, including queue workers, can be restored with the rolled-back code and data combination.

## Primary sources

* [Laravel official documentation: Custom Polymorphic Types](https://github.com/laravel/docs/blob/13.x/eloquent-relationships.md#custom-polymorphic-types)
* [Laravel official documentation: Package Development](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Relation: merging the map and strict mode](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/Relation.php)
* [HasRelationships: getting type names and resolving classes](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php)
* [MorphOneOrMany: keeping type names, query conditions and saving](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php)
* [MorphTo: type resolution during eager loading](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Database/Eloquent/Relations/MorphTo.php)
* [Laravel official tests: registering and resolving morph maps](https://github.com/laravel/framework/blob/v13.42.0/tests/Database/DatabaseEloquentRelationTest.php)
* [Laravel official tests: strict reads and writes](https://github.com/laravel/framework/blob/v13.42.0/tests/Database/DatabaseEloquentStrictMorphsTest.php)


## Related topics

- [Registering dynamic relationships in packages](https://kawax.biz/en/advanced/package-dynamic-relations.md)
- [Advanced Topics](https://kawax.biz/en/advanced/index.md)
- [Custom cache stores and contracts in packages](https://kawax.biz/en/advanced/package-cache-drivers.md)
- [Custom session drivers in packages](https://kawax.biz/en/advanced/package-session-drivers.md)
- [Extending database connections in packages](https://kawax.biz/en/advanced/package-database-connections.md)


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