In Eloquent, how do morphTo, morphMany and morphToMany model polymorphic relations, and why call Relation::enforceMorphMap() in production apps?
answer
- commentable_type plus commentable_id
- morphTo on the child, morphMany on parents
- taggables pivot for morphToMany
- fully qualified class name stored by default
- ClassMorphViolationException for unmapped models
basics
~20 sPolymorphic relations store a type and an id on the child so one model can belong to several parent types. enforceMorphMap() stores short aliases instead of class names and throws for unmapped models, so renames and new models cannot corrupt the type column.
solid answer
~40 sA polymorphic relation lets one child model belong to several parent types. `Comment::commentable()` returns `morphTo()`, and `Course` and `Lesson` each declare `morphMany(Comment::class, 'commentable')`, so the comments table holds `commentable_type` and `commentable_id`. `morphToMany(Tag::class, 'taggable')` does the same for many-to-many through a `taggables` pivot, with `morphedByMany()` as the inverse on `Tag`. By default the type column stores the parent's fully qualified class name, which breaks old rows when a class is renamed or moved. `Relation::enforceMorphMap(['course' => Course::class, ...])` in `AppServiceProvider::boot()` stores aliases instead and makes an unmapped model throw `ClassMorphViolationException`. The trade-off to mention is that the database cannot enforce a foreign key on the polymorphic id.
go deeper
Recall that a polymorphic relation stores a type and an id so comments can belong to courses or lessons.
Explain which side declares morphTo versus morphMany or morphToMany, the column naming, and what the type column stores by default.
Use enforceMorphMap to decouple stored data from class names, plan the data migration when adding it, and handle the missing foreign-key constraint.
Decide whether polymorphism belongs in the schema at all, or whether separate tables with real constraints serve the domain better.
## What a polymorphic relation is Sometimes one kind of child belongs to several kinds of parent. In a course platform, **comments** can be left on a `Course` or on a `Lesson`. Instead of `course_id` and `lesson_id` columns, a **polymorphic** relation stores two columns on the child: - `commentable_type` — which parent model the row belongs to; - `commentable_id` — that parent's key. ```php class Comment extends Model { public function commentable(): MorphTo { return $this->morphTo(); } } class Course extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } ``` `Lesson` declares the same `morphMany()`. The name `commentable` becomes the column prefix; `morphTo()` guesses it from its method name. ## The family | Method | Declared on | Meaning | |---|---|---| | `morphTo()` | the child (`Comment`) | "my parent, whatever type it is" | | `morphOne(Image::class, 'imageable')` | each parent | one polymorphic child | | `morphMany(Comment::class, 'commentable')` | each parent | many polymorphic children | | `morphToMany(Tag::class, 'taggable')` | each parent (`Course`, `Lesson`) | many-to-many through a `taggables` pivot with `tag_id`, `taggable_type`, `taggable_id` | | `morphedByMany(Course::class, 'taggable')` | the shared model (`Tag`) | the inverse, one method per parent type | ## What goes into the type column By default Eloquent writes the parent's **fully qualified class name**, `App\Models\Course`, into `commentable_type`. That couples stored data to your namespace: move or rename the class and every existing row points at a class that no longer exists, so `$comment->commentable` fails for old rows. A **morph map** stores short aliases instead: ```php use Illuminate\Database\Eloquent\Relations\Relation; public function boot(): void { Relation::enforceMorphMap([ 'course' => Course::class, 'lesson' => Lesson::class, ]); } ``` - `Relation::morphMap([...])` registers aliases: `getMorphClass()` then returns `course`, and reads translate `course` back to the class. - `Relation::enforceMorphMap([...])` registers the same map **and requires it**: asking an unmapped model for its morph class, which happens whenever it is written into a type column, throws `ClassMorphViolationException`. A developer who adds a new commentable model without mapping it gets an error in development instead of silently storing a class name. - `Relation::getMorphedModel('course')` returns the class for an alias. Call it in `AppServiceProvider::boot()`. When introducing a map to an existing app, the docs warn that every stored `*_type` value holding a class name must be converted to its alias. ## Trade-offs to state in an interview 1. **No foreign-key constraint.** The database cannot enforce that `commentable_id` exists in whichever table `commentable_type` names, so orphans are possible and cleanup is the application's job. 2. **Queries branch by type.** Eager loading a `morphTo` runs one query per parent type present in the results. 3. **Indexes** should cover the type and id columns together, since every lookup filters on both. 4. **Stability** of the type values is a data contract; the morph map makes it explicit. ## Laravel 13 note When a polymorphic many-to-many uses a custom pivot class (one extending `MorphPivot`) and the table name is inferred, Laravel 13 now infers a **plural** name. Apps that relied on the old singular name should set the table explicitly on the pivot class. ## Choosing polymorphism deliberately Polymorphic relations fit when the child behaves identically for every parent, as comments, tags, attachments and activity logs usually do. They fit poorly when: - each parent type needs different columns or rules on the child, which ends in nullable columns and type checks; - referential integrity matters more than table count, because separate `course_comments` and `lesson_comments` tables can carry real foreign keys; - reports routinely join across the child and one specific parent type, where the extra type filter and the missing constraint cost clarity. When a map is in place, `$course->getMorphClass()` returns the alias and `Relation::getMorphedModel('course')` returns the class, which is useful in data migrations that rewrite stored class names to aliases.
- What is the difference between `Relation::morphMap()` and `Relation::enforceMorphMap()`?Both register aliases for the type column. `enforceMorphMap()` also turns on `requireMorphMap()`, so any model not in the map throws `ClassMorphViolationException` when its morph class is requested, for example when a comment is saved against it. With plain `morphMap()`, an unmapped model silently falls back to storing its class name.
- Why can the database not protect `commentable_id` with a foreign-key constraint?A foreign key must reference one table, but `commentable_id` points at courses or lessons depending on the row's type value. The integrity of the pair is therefore enforced only by the application, so deleting a course needs explicit cleanup of its comments.
- What changed in Laravel 13 for polymorphic pivot table names?When a polymorphic many-to-many uses a custom pivot class and the table name is inferred, Laravel 13 infers a plural name. An app that relied on the old singular inferred name should declare the table on the pivot class explicitly.
saying these in an interview costs you the question
- morphTo stores the parent's table name in the type column
- enforceMorphMap only renames aliases and never throws
- Polymorphic ids can have a normal foreign-key constraint
- A morph map can be added to a live app without touching stored rows
- morphToMany needs a separate pivot table per parent model