For comments across many models should I use a polymorphic relation or separate tables, given indexing and foreign keys?
Question
In a Laravel app, comments attach to three different models: posts, products, and tickets. The classic route is a single `comments` table via `morphMany`, but not being able to set a real foreign key bothers me. The alternative is a separate table per type (post_comments, product_comments, ticket_comments) where I can set real FKs and cascade deletes. Which is healthier for indexing and referential integrity? How should I weigh the ease of adding a new type against database integrity?
Answer
Short answer: the one big cost of a polymorphic relation is that you can’t set a real foreign key.
Short answer
With 3 stable types where integrity matters, I’d prefer a single-table “exclusive arc” (nullable typed FKs + a CHECK); if you stay pure morph, a morph map is mandatory.
Why
-
The real cost of polymorphic: no referential integrity. Because
commentable_idpoints at three different tables, the database can’t enforce a foreign key. Delete a post and its comments are orphaned; cascade deletes and consistency fall entirely to app code (observers, events, cron). You’re hand-simulating the database’s strongest guarantee. -
Separate tables: clean integrity, but proliferation. Real FKs, cascades, per-type constraints — all DB-guaranteed. The cost: N tables, duplicated schema and code, migration overhead, and a UNION for queries like “all of a user’s comments.”
-
The middle ground — an exclusive arc (one table + typed FKs). A single
commentstable wherepost_id,product_id,ticket_idare all nullable, each a real FK, with a CHECK that exactly one is set. You keep one table AND get real foreign keys. The cost: adding a new type means a new column, i.e. a schema change.CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, post_id BIGINT REFERENCES posts(id) ON DELETE CASCADE, product_id BIGINT REFERENCES products(id) ON DELETE CASCADE, ticket_id BIGINT REFERENCES tickets(id) ON DELETE CASCADE, CONSTRAINT one_target CHECK ( (post_id IS NOT NULL)::int + (product_id IS NOT NULL)::int + (ticket_id IS NOT NULL)::int = 1 ) ); CREATE INDEX ON comments (post_id) WHERE post_id IS NOT NULL; CREATE INDEX ON comments (product_id) WHERE product_id IS NOT NULL; -
Indexing is easy either way. In morph, the
(commentable_type, commentable_id)composite index (Laravel’s default) serves “comments for this entity” well. In the arc, a partial index per FK is enough. So indexing isn’t the deciding factor.
What to do
-
If you go morph, don’t do it without a morph map. By default Laravel writes the full class name (
App\Models\Post) intocommentable_type. UseenforceMorphMapto store a short key ('post') instead: the index and storage shrink, and — most importantly — renaming a class no longer breaks your data. -
The decision driver: are the types stable, and how critical is integrity. If types rarely change and integrity is critical (payments, tickets), go arc or separate tables. If types are constantly added and flexibility outweighs integrity, go morph + app-level integrity.
Bottom line: personally, with 3 stable types and a real need for FKs, I’d pick the exclusive arc — one table stays, and cascade deletes live in the database. If the set of types is open-ended and will keep growing, I’d go pure morph, pin my keys with enforceMorphMap, and guarantee orphan cleanup in an observer on the code side. I’d make the call not on “which is elegant” but on “am I willing to hand referential integrity to the DB or not.”
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.