Rails.application.routes.draw do
resources :articles do
resources :comments, shallow: true, only: %i[index new create show edit update destroy]
end
root "articles#index"
end
class CommentsController < ApplicationController
before_action :set_article, only: %i[index new create]
before_action :set_comment, only: %i[show edit update destroy]
rescue_from ActiveRecord::RecordNotFound, with: :not_found
def index
@comments = @article.comments.recent.page(params[:page])
end
def new
@comment = @article.comments.build
end
def create
@comment = @article.comments.build(comment_params)
if @comment.save
redirect_to [@article, @comment], notice: "Comment posted."
else
render :new, status: :unprocessable_entity
end
end
def show; end
def edit; end
def update
if @comment.update(comment_params)
redirect_to @comment, notice: "Comment updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@comment.destroy!
redirect_to @comment.article, notice: "Comment removed."
end
private
def set_article
@article = Article.find(params[:article_id])
end
def set_comment
scope = params[:article_id] ? Article.find(params[:article_id]).comments : Comment
@comment = scope.find(params[:id])
end
def comment_params
params.require(:comment).permit(:body)
end
def not_found
redirect_to articles_path, alert: "Comment not found.", status: :see_other
end
end
class Comment < ApplicationRecord
belongs_to :article, counter_cache: true
validates :body, presence: true, length: { maximum: 10_000 }
scope :recent, -> { order(created_at: :desc) }
def to_param
"#{id}-#{body.parameterize.first(40)}"
end
end
This snippet shows how a nested resource is exposed in Rails without paying the usual cost of deeply nested URLs. In config/routes.rb, comments is nested under articles but declared with shallow: true, which is the crux of the pattern: routes that need the parent for context (index, new, create) keep the /articles/:article_id/... prefix, while routes that already have a unique comment id (show, edit, update, destroy) collapse to flat /comments/:id paths. The result is short, canonical URLs for individual records and correctly scoped collection routes, without hand-writing two route blocks.
Because shallow routing produces two different parameter shapes, the controller must load records differently depending on the action. In CommentsController, a before_action :set_article runs only for the parent-scoped actions and looks the article up by params[:article_id]. A second before_action :set_comment handles the member actions, and it deliberately scopes the lookup through @article.comments when a parent is present, falling back to a global Comment.find otherwise. Scoping the query through the association is what enforces the tenancy boundary: a request for a comment id that does not belong to the given article raises ActiveRecord::RecordNotFound instead of silently loading another article's data.
The create action builds through @article.comments, so the foreign key is set implicitly and mass-assignment cannot be tricked into reparenting a comment. comment_params uses strong parameters to whitelist only body, keeping article_id server-controlled. The redirect_to [@article, @comment] call leans on polymorphic routing so Rails picks the shallow member path automatically.
In Comment model, a default_scope-free ordering is exposed through an explicit recent scope, and a belongs_to :article, counter_cache: true keeps articles.comments_count accurate for cheap display. The not_found rescue centralizes error handling so both lookup strategies degrade to a 404. This approach is worth reaching for whenever a child resource is always accessed in the context of its parent for listing and creation, but benefits from stable, shareable URLs for individual items. The main pitfall is forgetting to scope member lookups through the parent, which reintroduces the authorization hole shallow routing otherwise sidesteps.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.