spatie/laravel-morph-map-generator
Stop forgetting to register models in your Laravel morph map. This package auto-generates morph maps at boot, letting each model register itself via a simple getMorphClass method. Includes configurable autogeneration and a base directory setting for non-default model locations.
The package solves one specific problem: keeping Laravel's morph map (the array of alias => class pairs registered via Relation::morphMap()) in sync with the models in your app. Instead of hand-maintaining that array in a service provider, each model declares its own alias via getMorphClass(), and the package discovers the models on boot and registers the map for you.
Minimum viable setup (about 10 minutes):
composer require spatie/laravel-morph-map-generator
php artisan vendor:publish --provider="Spatie\LaravelMorphMapGenerator\MorphMapGeneratorServiceProvider" --tag="config"
Add the method to a model you already have polymorphic relations on:
class Post extends Model
{
public function getMorphClass(): string
{
return 'post';
}
}
Then confirm the map actually contains your entry:
php artisan tinker
>>> Illuminate\Database\Eloquent\Relations\Relation::morphMap();
// ['post' => 'App\Models\Post', ...]
Where to look first:
config/morph-map-generator.php — autogenerate, paths, base_directory, base_models, ignored_models, cache.php artisan morph-map:cache and php artisan morph-map:clear.App\Models\Post string in your *_type columns.First real use case: you have an existing app with a hand-written map:
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'post' => \App\Models\Post::class,
'video' => \App\Models\Video::class,
]);
Delete that array, add getMorphClass() to each model returning the same strings you already use in the database, and let the package generate the map. Because the alias values are unchanged, no data migration is needed — that is the safe first migration path.
This is the pattern the package is designed around and the one to adopt on day one:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use RuntimeException;
abstract class BaseModel extends Model
{
public function getMorphClass(): string
{
throw new RuntimeException(static::class . ' does not implement getMorphClass()');
}
}
Point the config at it so every other class in app/ is ignored:
'base_models' => [
\App\Models\BaseModel::class,
],
Now any model that extends BaseModel without declaring an alias blows up during map generation — which is exactly what you want, at build/boot time rather than at 3am when a morphTo returns null.
Two viable strategies:
Explicit per model (best for public APIs, URLs, and anything user-visible):
public function getMorphClass(): string
{
return 'blog-post';
}
Derived by convention (best for large model counts), via a custom resolver in a service provider boot():
use Spatie\LaravelMorphMapGenerator\MorphMapGenerator;
use Illuminate\Support\Str;
MorphMapGenerator::resolveUsing(
fn ($model) => Str::singular($model->getTable())
);
This gives posts → post, blog_comments → blog_comment, and requires zero boilerplate. The resolver only applies to models that don't return a value themselves — but in practice you pick one approach and stick to it, because mixing them creates confusion about where an alias comes from.
autogenerate => true regenerates the map on every boot, which means directory scanning and class loading on every request. That's fine in dev, wrong in production. Production should be:
'autogenerate' => false, // or leave true; the cached file wins
'cache' => [
'type' => Spatie\LaravelMorphMapGenerator\Cache\FilesystemMorphMapCacheDriver::class,
'path' => storage_path('app/morph-map-generator'),
],
Then add to your deploy script, alongside config:cache:
php artisan morph-map:cache
On Laravel 11+ the package hooks into the optimize / optimize:clear commands, so if your pipeline runs php artisan optimize, caching is already covered — do not duplicate it. After deploying, restart queue workers (queue:restart), otherwise long-lived workers hold the previous map.
Consider the Laravel cache driver instead of the filesystem driver if you deploy to ephemeral containers where storage/ isn't shared between build and runtime:
'cache' => [
'type' => Spatie\LaravelMorphMapGenerator\Cache\LaravelMorphMapCacheDriver::class,
'key' => 'morph-map',
],
Discovery is scoped to paths (default app_path()), so App\Models\User may be excluded if you moved it, and package models are never found. Merge them in with Laravel's native API — it merges with the generated map, it doesn't replace it:
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'user' => \App\Models\User::class,
'team-member' => \App\Domain\Teams\Models\TeamMember::class, // outside app/
]);
For codebases with a Domain layer (src/Domain/*/Models), the cleanest setup is to widen discovery rather than hand-list:
'base_directory' => '/',
'paths' => [
base_path('src/Domain'),
base_path('src/App'),
],
'base_models' => [\App\Support\BaseModel::class],
base_directory exists precisely because the package computes class names from file paths relative to App; when the app lives in src/App and models in src/Domain, that assumption breaks and base_directory => '/' restores it.
Once the map exists, everything downstream just works and gets shorter strings in the DB:
// Stored as type = 'post', id = 42
$comment->commentable()->associate($post)->save();
// Eager loading works across aliases
$comments = Comment::with('commentable')->get();
// Alias-aware queries
Comment::whereHasMorph('commentable', [Post::class])->get();
// Returns Eloquent models, not strings
$comment->commentable; // App\Models\Post
Because aliases replace FQCNs in *_type columns, the immediate practical win is index size and portability: renaming a namespace no longer orphans every polymorphic row.
In tests the map regenerates on boot, so a newly created model is immediately available. Two caveats:
'cache' => ['type' => null]-style behaviour by running php artisan morph-map:clear in setUp.it('keeps morph aliases stable', function () {
expect(Relation::morphMap())->toMatchArray([
'post' => \App\Models\Post::class,
'video' => \App\Models\Video::class,
]);
});
The morph_type column is data, not configuration. Changing an alias — whether by editing getMorphClass() or by changing the resolver — silently breaks every existing row. Deriving from Str::singular($model->getTable()) is safe only because table names rarely change; if you rename a table, write a data migration for *_type in the same release. Treat alias values as a schema-level contract.
Duplicate aliases don't error loudly. Two models returning 'post' will collide, and one wins depending on iteration order. You won't notice until morphTo hydrates the wrong class. Guard with the snapshot test above and keep aliases unique by construction.
autogenerate => true in production is a performance bug, not a feature. It scans directories and loads classes on every boot — every request under PHP-FPM, or once per worker under Octane if you're lucky. Cache the map and deploy it.
Stale cached maps are the #1 source of "why is my new model null in morphTo". Symptom: works locally, broken after deploy, or works in one container but not another. Debug in this order: Relation::morphMap() in tinker → does the alias exist → if not, php artisan morph-map:clear && php artisan morph-map:cache → if still missing, is the model inside a configured path and does it extend a configured base_model?
The base-model exception strategy has a blast radius. If BaseModel::getMorphClass() throws, any model in a scanned path that extends it and forgot the method will break map generation — including models you inherited from a starter kit or a package that copied code into app/. Keep ignored_models populated with abstract classes, DTOs, and legacy models you're not ready to convert:
'ignored_models' => [
\App\Models\Legacy\OldInvoice::class,
],
Discovery only sees paths. The default App\Models\User in Laravel 11+ skeletons and everything in vendor/ need Relation::enforceMorphMap(). Remember that enforcement mode is global: once you call it, any model used in a polymorphic relation without an alias throws. That's usually a feature — it catches missing entries — but it will surprise you in tests that use ad-hoc anonymous model classes.
Beware base_directory typos. Early releases shipped a broken default here (fixed in 1.0.5). If the generated map contains mangled class names like App\App\Models\Post or paths that don't resolve, that's the setting to inspect before anything else.
Resolver closures must be deterministic and side-effect free. The README's own warning applies: an unstable resolver means unstable *_type values. Never key off auth()->id(), timestamps, or anything environment-dependent. Also keep aliases short — morphs() creates a 255-char string column, but a 60-char alias is still a poor primary discriminator for an indexed column.
Where to extend rather than fight the package:
cache.type; useful for Redis or a read-only build artifact baked into a container image.getMorphClass() on the model always beats the global resolver.Relation::enforceMorphMap(), not config gymnastics.Class loading is now tolerant (1.4.1). Recent versions let Laravel handle class-loading errors during generation rather than dying mid-scan, but don't rely on it: a genuinely broken class in app/ will still leave the map incomplete. Run morph-map:cache in CI so a bad model fails the build, not the deployment.
Laravel version support is current. 1.3.1 added Laravel 12 and 1.4.0 added Laravel 13, with PHP 8.3/8.4 tested. Pin ^1.4 in composer.json to get the optimize-command integration and the class-loading fix; anything below 1.3.0 lacks the Laravel 11+ optimize hooks and will need a manual deploy step.
How can I help you explore Laravel packages today?