Polymorphic Many-to-Many
Extends the existing polymorphic relation set (morphTo / morphMany / morphOne) with morphToMany and morphedByMany — for the classic case where one model attaches to several different parent types via a shared pivot table.
When to use it
The canonical example: Tags that can be attached to either Posts or Videos, all through a single taggables pivot table.
| Table | Columns |
|---|---|
posts | id, title, … |
videos | id, name, … |
tags | id, label |
taggables (pivot) | tag_id, taggable_type, taggable_id |
taggable_type discriminates between 'post' / 'video' / …; taggable_id points back at the corresponding parent row.
Schema
schema.createTable('posts', (t) => {
t.id()
t.text('title').notNull()
})
schema.createTable('videos', (t) => {
t.id()
t.text('name').notNull()
})
schema.createTable('tags', (t) => {
t.id()
t.text('label').notNull()
})
schema.createTable('taggables', (t) => {
t.text('tag_id').notNull()
t.text('taggable_type').notNull()
t.text('taggable_id').notNull()
t.primary('tag_id, taggable_type, taggable_id')
// Critical: lookup index for the inverse-side queries
t.index('taggable_type, taggable_id')
})Pivot tables for polymorphic many-to-many should not have a
deleted_atcolumn — same caveat as plainbelongsToMany.
Owning side — morphToMany
The model that "has many tags":
class Post extends BaseModel<PostAttrs> {
static table = 'posts'
static relations: Record<string, TRelationDefinition> = {
tags: {
type: 'morphToMany',
model: () => Tag,
pivot: 'taggables',
morphName: 'taggable', // → taggable_type + taggable_id on the pivot
typeValue: 'post', // stored in taggable_type
relatedPivotKey: 'tag_id', // pivot column that points at Tag
},
}
}
class Video extends BaseModel<VideoAttrs> {
static table = 'videos'
static relations: Record<string, TRelationDefinition> = {
tags: {
type: 'morphToMany',
model: () => Tag,
pivot: 'taggables',
morphName: 'taggable',
typeValue: 'video', // only thing that differs from Post.tags
relatedPivotKey: 'tag_id',
},
}
}| Option | Description | Default |
|---|---|---|
model | Lazy factory for the related (Tag-side) model | required |
pivot | Pivot table name | required |
morphName | Logical name; derives <name>_type + <name>_id on the pivot | required |
typeValue | Value written into the pivot's type column for this parent | required |
relatedPivotKey | Pivot column pointing at the related model | required |
typeColumn | Override the pivot's type column | <morphName>_type |
idColumn | Override the pivot's id column | <morphName>_id |
localKey | Column on THIS model | this.primaryKey |
relatedKey | Column on the related (Tag) model | Tag.primaryKey |
Inverse side — morphedByMany
The Tag model wants to list "all Posts I'm attached to" and "all Videos I'm attached to" — one relation per parent type:
class Tag extends BaseModel<TagAttrs> {
static table = 'tags'
static relations: Record<string, TRelationDefinition> = {
posts: {
type: 'morphedByMany',
model: () => Post,
pivot: 'taggables',
morphName: 'taggable',
typeValue: 'post',
relatedPivotKey: 'tag_id',
},
videos: {
type: 'morphedByMany',
model: () => Video,
pivot: 'taggables',
morphName: 'taggable',
typeValue: 'video',
relatedPivotKey: 'tag_id',
},
}
}morphedByMany takes the same options as morphToMany. The model factory returns the parent class for the relation; the resolver joins parent.id to pivot.taggable_id, filtered by taggable_type = typeValue.
Lazy loading
// Owning side
const post = await Post.findOrFail(postId)
const tags = await post.related('tags').get()
// Inverse — Tag → Posts
const tag = await Tag.findOrFail(tagId)
const posts = await tag.related('posts').get()
const videos = await tag.related('videos').get()Eager loading
// Tags for many posts in one round-trip
const posts = await Post.query().whereIn('id', ids).with(['tags']).get()
posts[0].relations.tags // Tag[]
// Posts + Videos for many tags
const tags = await Tag.query().with(['posts', 'videos']).get()
tags[0].relations.posts // Post[]
tags[0].relations.videos // Video[]Each morphToMany / morphedByMany with() results in one batched SELECT joining the pivot and applying WHERE pivot.<type> = ? + WHERE pivot.<idCol> IN (?, ?, …).
Existence queries — has / whereHas
// Posts that have at least one tag
const tagged = await Post.query().has('tags').get()
// Tagless posts
const orphans = await Post.query().doesntHave('tags').get()
// Posts tagged 'workers'
const workers = await Post.query()
.whereHas('tags', (q) => q.whereEq('label', 'workers'))
.get()
// Inverse: tags attached to a Hello post
const tags = await Tag.query()
.whereHas('posts', (q) => q.whereLike('title', 'Hello%'))
.get()Without whereHas filters, the EXISTS subquery hits the pivot table alone — no join — which is the cheap path.
Attaching and detaching
The ORM doesn't ship a sugar method for managing the pivot itself — write the inserts/deletes against the pivot directly with db.batch():
// Tag p1 with t1, t2 in one batch
await env.DB.batch([
env.DB.prepare(
"INSERT INTO taggables (tag_id, taggable_type, taggable_id) VALUES (?, ?, ?)"
).bind('t1', 'post', 'p1'),
env.DB.prepare(
"INSERT INTO taggables (tag_id, taggable_type, taggable_id) VALUES (?, ?, ?)"
).bind('t2', 'post', 'p1'),
])
// Detach
await env.DB
.prepare("DELETE FROM taggables WHERE tag_id = ? AND taggable_type = ? AND taggable_id = ?")
.bind('t1', 'post', 'p1')
.run()Sugar methods for attach() / detach() / sync() may land in a later release — track the package issues for progress.
Caveats
- Pivot must not have
deleted_at— soft-delete scope on the related model would produce an ambiguous-column error during JOIN. - One
morphedByManyper parent type on the related-side model. There's no built-in "give me all parents regardless of type" — model that as multiple relations or use a raw query. - Pivot extras (timestamps, role, position…) on the pivot aren't reflected in the relation result. If you need them, query the pivot directly or model it as a regular model with
belongsToto both sides.