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).
Declaring which associations get routes โ
By default every has_many and has_one whose child is a registered resource gets a nested route. Name the ones you want and the rest are not drawn:
register_resource ::Company, associations: %i[properties company_profile]associations: [] draws none. Naming an association that is not a has_many or has_one, or whose child is not registered in that portal, fails the boot rather than quietly drawing one route fewer.
To make declaring them the rule rather than the exception, flip the default so that a resource naming none gets none:
# config/initializers/plutonium.rb
Plutonium.configure do |config|
config.nested_association_routes = :declared # default: :detected
endThe mode only decides what silence means. associations: works the same either way, and top-level routes are untouched by both.
Two things to know before turning it on:
- It applies to every portal at once, and every resource that names nothing loses its nested routes. On an existing app, expect to add
associations:in several places. - A policy's
permitted_associationsrenders a panel on the show page that links to the nested route. An association permitted there but omitted here leaves that panel pointing at a route that does not exist. The two lists have to agree.
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), building the record on the parent's association so a scoped association supplies its defaults. - 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.
Scoped associations โ
The record is built on the parent's association (company.properties.new), so an association that carries a scope contributes its equality conditions as defaults:
class Company < ResourceRecord
has_many :published_properties, -> { where(published: true) },
class_name: "Property"
endCreating through /companies/123/nested_published_properties sets published: true. It has to: the index honours the same scope, so a record created without it is filtered out of the list it was created from.
Only equality conditions become attributes. A scope like -> { where("expires_at > ?", Time.current) } cannot supply one, so an association scoped that way still creates records its own index will not list. Prefer an equality scope for any association you expose as a nested route.
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
