Stop Storing PHP Class Names in Laravel Polymorphic Types
Use enforced morph maps and a staged data migration so polymorphic rows survive model renames.
Laravel polymorphic relationships solve a useful modelling problem: one table can belong to more than one kind of model. A comment can belong to a post or a video. An attachment can belong to an invoice or a support ticket. The database stores an ID and a type, then Eloquent resolves the type to the correct model.
The default type is the model’s fully qualified PHP class name. That is convenient at the start, but it turns an internal namespace into persisted data. Moving App\Models\Post to App\Content\Post is no longer only a refactor. Existing rows still contain the old class name, and their relationships can fail after the old class disappears.
A morph map replaces class names with stable aliases such as post and video. The important part is not the two-line map. It is the rollout. An existing application needs a transition that can read old rows, write new aliases, backfill safely, verify the result, and only then reject unmapped types.
This article targets Laravel 13 and PHP 8.3 or later. The examples use a comments table with commentable_id and commentable_type columns, but the deployment sequence applies to any Eloquent polymorphic relationship.
Laravel polymorphic relationships solve a useful modelling problem: one table can belong to more than one kind of model. A comment can belong to a post or a video. An attachment can belong to an invoice or a support ticket. The database stores an ID and a type, then Eloquent resolves the type to the correct model.
The default type is the model’s fully qualified PHP class name. That is convenient at the start, but it turns an internal namespace into persisted data. Moving App\Models\Post to App\Content\Post is no longer only a refactor. Existing rows still contain the old class name, and their relationships can fail after the old class disappears.
A morph map replaces class names with stable aliases such as post and video. The important part is not the two-line map. It is the rollout. An existing application needs a transition that can read old rows, write new aliases, backfill safely, verify the result, and only then reject unmapped types.
This article targets Laravel 13 and PHP 8.3 or later. The examples use a comments table with commentable_id and commentable_type columns, but the deployment sequence applies to any Eloquent polymorphic relationship.
Inventory every polymorphic type before backfilling
Do not begin with a blind update of the two values you remember. First list the distinct values in every polymorphic type column:
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
$types = DB::table('comments')
->select('commentable_type')
->distinct()
->orderBy('commentable_type')
->pluck('commentable_type');
foreach ($types as $type) {
printf("%s\n", $type);
}Run the inventory in the environment whose data will be migrated. The output may contain old namespaces, aliases from an earlier partial migration, or types created by code that no longer exists. Each value needs a decision. A type should map to a current model, be repaired according to a known business rule, or be treated as an orphan for explicit cleanup. Guessing a destination from a similar class name can attach comments to the wrong kind of record.
This query is read-only, but DISTINCT may still be expensive on a large table without a useful index. Check the actual schema and query plan before running the inventory on a large production table.
Keep the inventory result with the deployment plan. It defines the finite set the backfill must eliminate. It also exposes polymorphic tables outside the main example. Updating comments.commentable_type while forgetting attachments.attachable_type leaves the application only partly migrated.
Backfill in bounded, restartable batches
For a small table, one guarded UPDATE per legacy class may be enough. On a busy or large table, a bounded command is easier to stop, observe, and resume. The following command updates IDs in batches and retains the legacy type in the WHERE clause:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class BackfillCommentMorphTypes extends Command
{
protected $signature = 'comments:backfill-morph-types {--batch=1000}';
protected $description = 'Replace legacy commentable class names with morph aliases';
public function handle(): int
{
$batchSize = max(1, min((int) $this->option('batch'), 5000));
$map = [
Post::class => 'post',
Video::class => 'video',
];
foreach ($map as $legacyType => $alias) {
DB::table('comments')
->where('commentable_type', $legacyType)
->orderBy('id')
->chunkById($batchSize, function ($rows) use ($legacyType, $alias): void {
$ids = $rows->pluck('id');
$updated = DB::table('comments')
->whereIn('id', $ids)
->where('commentable_type', $legacyType)
->update(['commentable_type' => $alias]);
$this->line("{$legacyType} -> {$alias}: {$updated}");
}, column: 'id');
}
return self::SUCCESS;
}
}chunkById() gives each scan a stable numeric boundary instead of using offsets that can shift while rows change. The update is also guarded by the old type. If another process already converted a row, the command will not overwrite its newer value. Rerunning the command is safe because converted rows no longer match the legacy type.
The command intentionally avoids one transaction around the entire table. A single large transaction can retain locks and old row versions for too long. The tradeoff is partial progress: after an interruption, some batches are converted and some are not. Phase one’s permissive map makes that mixed state readable, and the command’s restartable design completes the rest.
Get Udit Rawat’s stories in your inbox
Join Medium for free to get updates from this writer.
This is still not a universal online-migration recipe. Batch size, lock behavior, replication pressure, and execution time depend on the database engine and workload. Test against representative data, watch the database while it runs, and reduce the batch when the operational cost is too high. If IDs are UUIDs or the table has a different key, adapt the cursor column instead of copying id blindly.
Verify the data, then enforce the contract
After the backfill, repeat the distinct-value inventory. For this example, the only accepted values are post and video. Count legacy and unknown values explicitly, and stop the rollout if the count is not zero. Do the same for every polymorphic table identified earlier.
Only after that verification should the provider enforce the map:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
], merge: false);
}
}Enforcement changes a missed mapping from silent class-name persistence into a failure. In Laravel 13, unmapped morph classes are rejected when Eloquent obtains a model’s morph class and when it resolves a stored morph type. That is why enabling enforcement before the backfill is unsafe: legacy class-name rows are unmapped values.
The failure is useful after migration. If a developer adds Podcast to a polymorphic relationship but forgets the map, the application does not quietly store App\Models\Podcast and recreate the original coupling. The missing alias is found during development or testing, where it can be fixed deliberately.
Test both the alias and the relationship
A narrow test should prove the persisted representation and the round trip:
<?php
declare(strict_types=1);
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores and resolves the stable post morph alias', function (): void {
$post = Post::factory()->create();
$comment = $post->comments()->create([
'body' => 'A useful comment.',
]);
$this->assertDatabaseHas('comments', [
'id' => $comment->getKey(),
'commentable_id' => $post->getKey(),
'commentable_type' => 'post',
]);
$resolved = Comment::query()
->with('commentable')
->findOrFail($comment->getKey())
->commentable;
expect($resolved)
->toBeInstanceOf(Post::class)
->and($resolved->is($post))
->toBeTrue();
});Asserting only getMorphClass() === 'post' tests configuration, but not the database write or inverse resolution. The feature test covers all three. Add one case per mapped type, especially when different modules register parts of the map.
Do not assert that arbitrary legacy class names still resolve after enforcement. Their rejection is the final contract. If a rollback must support the old application version, plan it before migration: the old code will not understand aliases unless the permissive map is deployed there too. Database conversion and application rollback cannot be designed independently.
Practical rollout checklist
Use stable aliases when a polymorphic type will outlive the model’s current namespace. Register the permissive map first, restart long-running workers, inventory every type column, and backfill with a process that is bounded and restartable. Verify the accepted value set from the database before switching to enforceMorphMap().
Do not use a polymorphic relationship merely to avoid choosing a clearer schema. If the related types have different ownership rules, lifecycle, or required fields, separate foreign keys or separate tables may express the domain better. A morph map fixes persistence coupling. It does not fix an ambiguous data model.
Treat alias changes like other persisted-value migrations. Renaming post to article later requires another compatibility and data plan, even if both names resolve to the same model temporarily. Stable does not mean impossible to change. It means the change is explicit, measured, and coordinated with stored data.
The main design decision is simple: database values should name durable domain types, not the PHP files that happen to implement them today.
Sources
- Laravel 13 Eloquent relationships: custom polymorphic types
- Laravel 13 API:
Relation::morphMap()andRelation::enforceMorphMap() - Laravel 13 source: relationship morph resolution
- Laravel 13 query builder: chunking results while updating
- Laravel 13 queues: workers and deployment
- Laravel 13 release notes and PHP support
Udit Rawat is a Senior Software Engineer with more than 10 years of experience and co-founder of Moosky AI.








