Nested Resources โ
Plutonium auto-generates nested routes from has_many and has_one associations on a registered parent. No manual route wiring โ belongs_to on the child plus register_resource for both is enough.
๐จ Critical โ
- One level only. Grandparent โ parent โ child nested routes are NOT supported. Use top-level routes for deeper relationships.
- Parent scoping beats entity scoping. When a parent is present,
default_relation_scopescopes via the parent, NOT viaentity_scope. Don't double-scope. - Named custom routes. When adding member/collection routes on a nested resource, always pass
as:โ otherwiseresource_url_forwill fail. - The parent is authorized for
:read?beforecurrent_parentreturns. The child policy receives the parent in its context.
Setup โ
rails g pu:res:scaffold Company name:string --dest=main_app
rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
rails g pu:res:conn Company Property --dest=admin_portalThen register both in the portal:
# packages/admin_portal/config/routes.rb
register_resource ::Company
register_resource ::Property # has belongs_to :company
register_resource ::CompanyProfile # has_one :company_profile on CompanyGenerated routes โ
Plutonium prefixes nested routes with nested_ so they don't conflict with the top-level routes:
| Route | Purpose |
|---|---|
/companies/:company_id/nested_properties | has_many index |
/companies/:company_id/nested_properties/new | new |
/companies/:company_id/nested_properties/:id | show |
/companies/:company_id/nested_company_profile | has_one show (no :id) |
/companies/:company_id/nested_company_profile/new | has_one new |
For has_one:
- Routes are singular (no
:idparam). - Index redirects to show (or new if no record exists).
- Only one record can exist per parent.
- Forms don't show the parent field (determined by URL).
Automatic behavior on nested routes โ
When the controller is hit via a nested route, Plutonium automatically:
- Resolves the parent via
current_parent, authorized for:read?. - Scopes queries via the parent association:
has_manyโparent.send(parent_association)(e.g.company.properties)has_oneโrelation.where(foreign_key => parent.id)with limit
- Assigns the parent on create (injected into
resource_params). - Hides the parent field in forms and displays (already determined by URL).
You don't add hidden parent fields or filter queries manually.
Controller methods โ
current_parent # parent record (e.g. Company instance)
current_parent_class # parent class (e.g. Company)
current_nested_association # association name (e.g. :properties)
parent_input_param # form param / association name (e.g. :company)Each nested route carries the key of its own registration, so the parent class and association are read from the route rather than reconstructed from the URL. That is what lets a resource registered singular: true act as a parent at all โ it contributes no id parameter, so there is nothing in the path to infer from.
Parent vs entity scoping โ
When a parent is present, parent scoping wins: default_relation_scope scopes via the parent association, NOT entity_scope. The parent was already authorized and entity-scoped during its own authorization โ double-scoping is redundant.
In the child's policy, just call default_relation_scope โ it handles both cases:
class PropertyPolicy < ResourcePolicy
relation_scope do |relation|
default_relation_scope(relation) # parent when present, entity_scope otherwise
end
endFor composite filtering on top of the default:
relation_scope do |relation|
default_relation_scope(relation).where(archived: false)
endURL generation โ
resource_url_for(...) with the parent: option:
# Child collection (has_many)
resource_url_for(Property, parent: company)
# => /companies/123/nested_properties
# Child record
resource_url_for(property, parent: company)
# => /companies/123/nested_properties/456
# New child
resource_url_for(Property, action: :new, parent: company)
# => /companies/123/nested_properties/new
# Edit child
resource_url_for(property, action: :edit, parent: company)
# => /companies/123/nested_properties/456/edit
# Singular (has_one)
resource_url_for(company_profile, parent: company)
# => /companies/123/nested_company_profile
resource_url_for(CompanyProfile, action: :new, parent: company)
# => /companies/123/nested_company_profile/new
# Interactions compose with parent
resource_url_for(property, parent: company, interaction: :archive)
resource_url_for(Property, parent: company, interaction: :import)
resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])Cross-package URLs โ
# From AdminPortal, generate URL to a CustomerPortal resource
resource_url_for(property, parent: company, package: CustomerPortal)Authorization context โ
The child policy receives the parent automatically:
class PropertyPolicy < ResourcePolicy
# parent => the Company instance
# parent_association => :properties
def create?
parent.present? && user.member_of?(parent)
end
def read?
parent.present? && record.company == parent
end
endThe parent is authorized for :read? before current_parent returns โ children inherit the parent's access requirements.
Parameter handling โ
The parent is injected into resource_params automatically:
# When creating a property under /companies/123/nested_properties
resource_params
# => { name: "...", company: <Company:123>, company_id: 123 }No hidden parent fields needed in forms.
Presentation hooks โ
Control whether the parent field appears in views/forms:
class PropertiesController < ::ResourceController
private
def present_parent? = true # show on displays (default: false)
def submit_parent? = false # include in forms (defaults to present_parent?)
endConditional โ show parent only when accessed standalone:
def present_parent?
current_parent.nil?
endCustom parent resolution โ
Override current_parent for non-default lookup:
class PropertiesController < ::ResourceController
private
def current_parent
@current_parent ||= Company.friendly.find(params[:company_id])
end
endCustom routes on nested resources โ
register_resource ::Property do
member do
get :analytics, as: :analytics
post :archive, as: :archive
end
collection do
get :report, as: :report
end
endGenerates /companies/:company_id/nested_properties/:id/analytics, etc.
Always pass as:
Without as:, resource_url_for(property, parent: company, action: :analytics) fails โ there's no named route to look up.
Compound uniqueness โ
Scope uniqueness to the parent FK:
class Property < ResourceRecord
belongs_to :company
validates :code, uniqueness: {scope: :company_id}
endWithout the scope, the same code in different companies would collide.
Custom association scope (for complex relationships) โ
When the parent path isn't a direct belongs_to, define a custom scope on the child:
class Property < ResourceRecord
scope :associated_with_organization, ->(org) {
joins(:company).where(companies: {organization_id: org.id})
}
endUseful when the child is nested under a grandparent-style entity. See Entity scoping โบ Three model shapes.
Breadcrumbs โ
Auto-include the parent: Companies > Acme Corp > Properties > Property #123.
Nesting limitations โ
Plutonium supports one level of nesting:
- โ
/companies/:company_id/nested_properties(parent โ child) - โ
/companies/:company_id/nested_properties/:property_id/nested_units(grandparent โ parent โ child)
For deeper hierarchies, use top-level routes plus association tabs on the show page (see Behavior โบ Policy โบ Association permissions and Resource โบ Definition โบ Custom page classes).
Related โ
- Entity scoping โ what happens when no parent is present
- Invites โ membership-based onboarding
- Behavior โบ Policy โ
relation_scope, parent context - Behavior โบ Controllers โ
current_parent, presentation hooks - App โบ Portals โ
register_resourceand custom member/collection routes
