Rails 8 Polymorphic Associations Guide

When building Rails applications, situations arise where a model needs to belong to multiple other models. Comments that can attach to posts, photos, or videos. Notifications that reference orders, messages, or friend requests. Tags that apply to articles, products, or users. Polymorphic associations solve this elegantly.

The Problem: One Model, Many Parents

Consider a commenting system. Without polymorphic associations, the options are limited and problematic:

  • Create separate tables: post_comments, photo_comments, video_comments — duplicating logic everywhere
  • Add multiple foreign keys: post_id, photo_id, video_id — with null columns and complex validations
  • Use polymorphic associations: one comments table that works with any model

The polymorphic approach keeps the schema clean and the code DRY. Rails 8 makes this straightforward with built-in support at both the migration and model levels.

Setting Up Polymorphic Associations

Start with the migration. The key is adding both a foreign key column and a type column that stores the associated model's class name:

# db/migrate/20260108120000_create_comments.rb
class CreateComments < ActiveRecord::Migration[8.0]
  def change
    create_table :comments do |t|
      t.text :body, null: false
      t.references :commentable, polymorphic: true, null: false, index: true
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end

    # Add composite index for efficient queries
    add_index :comments, [:commentable_type, :commentable_id, :created_at],
              name: "index_comments_on_commentable_and_created_at"
  end
end

The polymorphic: true option creates two columns: commentable_id (integer) and commentable_type (string). MySQL stores values like commentable_type = "Post" and commentable_id = 42.

Now define the models. The Comment model uses belongs_to with the polymorphic option:

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
  belongs_to :user

  validates :body, presence: true, length: { minimum: 2, maximum: 10_000 }

  scope :recent, -> { order(created_at: :desc) }
  scope :for_type, ->(type) { where(commentable_type: type) }
end

# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy

  # Other post logic...
end

# app/models/photo.rb
class Photo < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy

  # Other photo logic...
end

# app/models/video.rb
class Video < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy

  # Other video logic...
end

The as: :commentable option tells Rails which polymorphic interface this association uses. The name must match the belongs_to :commentable declaration in Comment.

Working with Polymorphic Records

Creating and querying polymorphic associations follows familiar Active Record patterns:

# Creating comments on different models
post = Post.find(1)
post.comments.create!(body: "Great article!", user: current_user)

photo = Photo.find(5)
photo.comments.create!(body: "Beautiful shot", user: current_user)

# Accessing the parent from a comment
comment = Comment.find(10)
comment.commentable # Returns Post, Photo, or Video instance
comment.commentable_type # Returns "Post", "Photo", or "Video"
comment.commentable_id # Returns the ID

# Querying comments by type
Comment.where(commentable_type: "Post").count
Comment.for_type("Photo").recent.limit(10)

# Eager loading to avoid N+1 queries
Comment.includes(:commentable, :user).recent.limit(20)

Building a Reusable Comments Controller

One controller can handle comments for all commentable types. Use a before action to load the parent resource:

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :set_commentable
  before_action :set_comment, only: [:destroy]

  def create
    @comment = @commentable.comments.build(comment_params)
    @comment.user = current_user

    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_back fallback_location: @commentable }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    @comment.destroy

    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@comment) }
      format.html { redirect_back fallback_location: @commentable }
    end
  end

  private

  def set_commentable
    @commentable = find_commentable
  end

  def find_commentable
    params.each do |name, value|
      if name =~ /(.+)_id$/
        model = $1.classify.constantize
        return model.find(value) if model.reflect_on_association(:comments)
      end
    end
    nil
  end

  def set_comment
    @comment = @commentable.comments.find(params[:id])
  end

  def comment_params
    params.require(:comment).permit(:body)
  end
end

Set up nested routes for each commentable resource:

# config/routes.rb
Rails.application.routes.draw do
  resources :posts do
    resources :comments, only: [:create, :destroy]
  end

  resources :photos do
    resources :comments, only: [:create, :destroy]
  end

  resources :videos do
    resources :comments, only: [:create, :destroy]
  end
end

MySQL Indexing Considerations

Polymorphic associations require careful indexing. The default index on (commentable_type, commentable_id) handles lookups like "all comments for this post." However, queries filtering by type alone need attention:

# This query benefits from a type-only index
Comment.where(commentable_type: "Post").count

# Add if this query pattern is common
add_index :comments, :commentable_type

For large tables, consider the composite index shown in the migration above. It supports queries that filter by type and sort by date — a common pattern for displaying recent comments.

Common Pitfalls and Solutions

Pitfall 1: Forgetting to eager load. Without includes(:commentable), displaying a list of comments triggers N+1 queries. Each comment.commentable call hits the database.

Pitfall 2: Type column mismatches. Rails stores the full class name in commentable_type. If a model gets renamed or moved to a module, existing records break. Use a migration to update type values when refactoring.

Pitfall 3: No foreign key constraints. MySQL foreign keys cannot reference multiple tables, so polymorphic associations lack database-level referential integrity. Use dependent: :destroy on the parent side and consider periodic cleanup jobs for orphaned records.

Pitfall 4: STI conflicts. When using Single Table Inheritance with polymorphic associations, Rails stores the STI subclass name (e.g., "GuestUser") not the base class. Query accordingly.

When to Use Polymorphic Associations

Polymorphic associations shine when:

  • Multiple models share identical relationship behavior (comments, tags, attachments)
  • The child model's logic is independent of the parent type
  • Adding new parent types should require no schema changes

Consider alternatives when the child model needs type-specific behavior. If comments on posts have different validations than comments on photos, separate models may be cleaner despite the duplication.

Summary

Polymorphic associations reduce duplication by allowing one model to belong to many others through a single interface. The setup involves a migration with polymorphic: true, a belongs_to with polymorphic: true on the child, and has_many with as: on each parent. Combined with a resourceful controller that dynamically finds the parent, this pattern enables clean, maintainable code for features like comments, attachments, and activity feeds across Rails 8 applications.

10 claps
← Back to Blog