Skip to content
Muhammet Şafak
tr
Asked by: Ceren Answered:

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

  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.

    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” 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

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind