# For comments across many models should I use a polymorphic relation or separate tables, given indexing and foreign keys?

> With 3 stable types and a real need for foreign keys, use a single-table exclusive arc; if the type set is open-ended, go pure morph with a morph map.

- Asked: 2026-09-27
- Answered: 2026-09-29
- Asked by: Ceren
- Tags: laravel, eloquent, data-modeling
- Source: https://muhammetsafak.com/just-ask/comments-polymorphic-relation-separate-tables-indexing-foreign-keys/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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

1. **The real cost of polymorphic: no referential integrity.** Because `commentable_id` points 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.

2. **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."

3. **The middle ground — an exclusive arc (one table + typed FKs).** A single `comments` table where `post_id`, `product_id`, `ticket_id` are 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.

   ```sql
   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;
   ```

4. **Indexing is easy either way.** In morph, the `(commentable_type, commentable_id)` composite index (Laravel's default) serves ["comments for this entity"](/blog/eloquent-relationships-hasmany-belongsto-and-eager-loading/) well. In the arc, a partial index per FK is enough. So indexing isn't the deciding factor.

## What to do

1. **If you go morph, don't do it without a morph map.** By default Laravel writes the full class name (`App\Models\Post`) into `commentable_type`. Use `enforceMorphMap` to store a short key (`'post'`) instead: the index and storage shrink, and — most importantly — renaming a class no longer breaks your data.

2. **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

- [Eloquent Relationships: hasMany, belongsTo, and Eager Loading](/blog/eloquent-relationships-hasmany-belongsto-and-eager-loading/) — Blog
- [Should I use chunkById instead of chunk when the same job also updates the rows it iterates?](https://muhammetsafak.com/just-ask/should-i-use-chunkbyid-instead-of-chunk-when-the-same-job/) — Just Ask
- [Should I bind a Money value object to Eloquent with a custom cast or with accessors/mutators?](https://muhammetsafak.com/just-ask/should-i-bind-a-money-value-object-to-eloquent-with-a/) — Just Ask
- [Should I enable Model::preventLazyLoading only locally or in production too?](https://muhammetsafak.com/just-ask/should-i-enable-model-preventlazyloading-only-locally-or-in-production-too/) — Just Ask
