Nested Resources
This guide covers setting up parent/child resource relationships.
Overview
Nested resources create URLs like /posts/1/nested_comments where comments belong to a specific post. Plutonium automatically handles:
- Scoping queries to the parent
- Assigning parent to new records
- Hiding parent field in forms
- URL generation with parent context
- Breadcrumb navigation
Plutonium supports both has_many (plural routes) and has_one (singular routes) associations.
Setting Up Nested Resources
1. Define the Association
# Parent model
class Post < ResourceRecord
has_many :comments, dependent: :destroy
has_one :post_metadata, dependent: :destroy
end
# Child models
class Comment < ResourceRecord
belongs_to :post
end
class PostMetadata < ResourceRecord
belongs_to :post
end2. Register Both Resources
# packages/admin_portal/config/routes.rb
AdminPortal::Engine.routes.draw do
register_resource ::Post
register_resource ::Comment
register_resource ::PostMetadata
endPlutonium automatically creates nested routes with a nested_ prefix based on the belongs_to association:
has_many routes (plural):
GET /posts/:post_id/nested_commentsGET /posts/:post_id/nested_comments/newGET /posts/:post_id/nested_comments/:id- etc.
has_one routes (singular):
GET /posts/:post_id/nested_post_metadataGET /posts/:post_id/nested_post_metadata/newGET /posts/:post_id/nested_post_metadata/edit
The nested_ prefix prevents route conflicts when the same resource is registered both as a top-level and nested resource.
3. Enable Association Panel
Show comments on the post detail page:
class PostPolicy < ResourcePolicy
def permitted_associations
%i[comments]
end
endHow It Works
Automatic Scoping
When accessing /posts/1/comments, queries are scoped to the parent:
# Internally uses: Comment.associated_with(post)
# Which resolves to: Comment.where(post: post)Automatic Parent Assignment
When creating a comment under a post, the parent is injected into params:
# POST /posts/1/comments
# resource_params automatically includes { post: <Post:1>, post_id: 1 }Automatic Field Hiding
The parent field (post) is automatically hidden in forms since it's determined by the URL.
Controller Helpers
current_parent
Returns the parent record resolved from the URL:
# URL: /posts/123/comments
current_parent # => Post.find(123)parent_route_param
The URL parameter containing the parent ID:
parent_route_param # => :post_idparent_input_param
The association name on the child model:
parent_input_param # => :postURL Generation
Use resource_url_for with the parent: option:
# Child collection (has_many)
resource_url_for(Comment, parent: @post)
# => /posts/123/nested_comments
# Child record
resource_url_for(@comment, parent: @post)
# => /posts/123/nested_comments/456
# New child form
resource_url_for(Comment, action: :new, parent: @post)
# => /posts/123/nested_comments/new
# Edit child
resource_url_for(@comment, action: :edit, parent: @post)
# => /posts/123/nested_comments/456/edit
# Singular resource (has_one)
resource_url_for(@post_metadata, parent: @post)
# => /posts/123/nested_post_metadata
resource_url_for(PostMetadata, action: :new, parent: @post)
# => /posts/123/nested_post_metadata/newWithin a nested context, parent: defaults to current_parent:
# In CommentsController under /posts/:post_id/nested_comments
resource_url_for(@comment) # parent: current_parent is automaticCross-Package URL Generation
Generate URLs for resources in a different package:
# From AdminPortal, generate URL to CustomerPortal resource
resource_url_for(@comment, parent: @post, package: CustomerPortal)Presentation Hooks
Control whether the parent field appears:
class CommentsController < ResourceController
private
# Show parent in displays (default: false when nested)
def present_parent?
current_parent.nil? # Only show when accessed standalone
end
# Allow changing parent in forms (default: same as present_parent?)
def submit_parent?
false
end
endPolicy Integration
Parent Authorization
The parent is authorized for :read? before being returned:
# Inside current_parent
authorize! parent, to: :read?Parent Scoping Context
For nested resources, policies receive parent and parent_association context. This is used for automatic query scoping:
class CommentPolicy < ResourcePolicy
# Available context:
# - parent: the parent record (e.g., Post instance)
# - parent_association: the association name (e.g., :comments)
# - entity_scope: the scoped entity (for multi-tenancy)
relation_scope do |relation|
relation = super(relation) # Applies parent scoping automatically
relation
end
endParent scoping takes precedence over entity scoping - when a parent is present, the policy scopes via the parent association rather than the entity scope. This prevents double-scoping since the parent was already authorized and entity-scoped.
has_many vs has_one Scoping
For has_many associations, scoping uses the association directly:
parent.send(parent_association) # e.g., post.commentsFor has_one associations, scoping uses a where clause:
relation.where(foreign_key => parent.id) # e.g., where(post_id: post.id)Entity Scope Fallback
When no parent is present (top-level resource access), entity_scope is used:
class CommentPolicy < ResourcePolicy
def create?
# entity_scope is available for multi-tenancy
entity_scope.present? && user.can_comment_on?(entity_scope)
end
endAdditional Scoping
Add role-based filtering on top of parent scoping:
class CommentPolicy < ResourcePolicy
relation_scope do |relation|
relation = super(relation) # Applies parent scoping first
if user.moderator?
relation
else
relation.where(approved: true).or(relation.where(user: user))
end
end
enddefault_relation_scope is Required
Plutonium verifies that default_relation_scope is called in every relation_scope to prevent multi-tenancy leaks:
# ❌ This will raise an error
relation_scope do |relation|
relation.where(approved: true) # Missing default_relation_scope!
end
# ✅ Correct
relation_scope do |relation|
default_relation_scope(relation).where(approved: true)
endWhen overriding an inherited scope but still wanting parent scoping:
class AdminCommentPolicy < CommentPolicy
relation_scope do |relation|
# Replace inherited scope but keep parent scoping
default_relation_scope(relation)
end
endAssociation Panels
Associations listed in permitted_associations appear on the parent's show page:
class PostPolicy < ResourcePolicy
def permitted_associations
%i[comments tags] # Shows panels for these
end
endEach panel displays:
- List of child records
- "Add" button linking to nested new action
- Edit/Delete actions per record
Nested Forms
Edit child records inline within the parent form:
1. Enable Nested Attributes
class Post < ResourceRecord
has_many :comments
accepts_nested_attributes_for :comments,
allow_destroy: true,
reject_if: :all_blank
end2. Configure as Nested Input
class PostDefinition < ResourceDefinition
input :comments, as: :nested
end3. Permit in Policy
class PostPolicy < ResourcePolicy
def permitted_attributes_for_create
[:title, :content, comments_attributes: [:id, :body, :_destroy]]
end
endhas_one Associations
Plutonium supports has_one associations with singular routes:
class Post < ResourceRecord
has_one :post_metadata, dependent: :destroy
endRoutes generated:
GET /posts/:post_id/nested_post_metadata- Show metadataGET /posts/:post_id/nested_post_metadata/new- New metadata formGET /posts/:post_id/nested_post_metadata/edit- Edit metadata formPATCH /posts/:post_id/nested_post_metadata- Update metadataDELETE /posts/:post_id/nested_post_metadata- Delete metadata
Note: No :id parameter in singular routes - only one record can exist per parent.
Nesting Depth
Plutonium supports one level of nesting:
/posts/:post_id/nested_comments(parent → child)/comments/:comment_id/nested_replies(parent → child)
Not supported:
/posts/:post_id/nested_comments/:comment_id/nested_replies(grandparent → parent → child)
Working with Deep Hierarchies
Use through associations for data access:
class Post < ResourceRecord
has_many :comments
has_many :replies, through: :comments
endCustom Routes on Nested Resources
Add member/collection routes:
register_resource ::Comment do
member do
post :approve
post :flag
end
collection do
get :pending
end
endGenerates nested routes:
POST /posts/:post_id/comments/:id/approvePOST /posts/:post_id/comments/:id/flagGET /posts/:post_id/comments/pending
Breadcrumbs
Nested resources automatically include parent in breadcrumbs:
Dashboard > Posts > My First Post > Comments > Comment #1Scoped Uniqueness
Validate uniqueness within parent:
class Comment < ResourceRecord
belongs_to :post
validates :position, uniqueness: { scope: :post_id }
endExample: Blog with Comments
Models
class Post < ResourceRecord
belongs_to :user
has_many :comments, dependent: :destroy
validates :title, :body, presence: true
end
class Comment < ResourceRecord
belongs_to :post
belongs_to :user
validates :body, presence: true
endPolicies
class PostPolicy < ResourcePolicy
def create?
user.present?
end
def read?
true
end
def permitted_attributes_for_create
%i[title body]
end
def permitted_attributes_for_read
%i[title body user created_at]
end
def permitted_associations
%i[comments]
end
end
class CommentPolicy < ResourcePolicy
def create?
user.present? && entity_scope.present?
end
def read?
true
end
def update?
record.user_id == user.id
end
def destroy?
record.user_id == user.id || entity_scope&.user_id == user.id
end
def permitted_attributes_for_create
%i[body]
end
def permitted_attributes_for_read
%i[body user created_at]
end
endController (if customization needed)
class CommentsController < ResourceController
private
def build_resource
super.tap do |comment|
comment.user = current_user
end
end
end