Skip to content

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_scope scopes via the parent, NOT via entity_scope. Don't double-scope.
  • Named custom routes. When adding member/collection routes on a nested resource, always pass as: โ€” otherwise resource_url_for will fail.
  • The parent is authorized for :read? before current_parent returns. The child policy receives the parent in its context.

Setup โ€‹

bash
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_portal

Then register both in the portal:

ruby
# packages/admin_portal/config/routes.rb
register_resource ::Company
register_resource ::Property        # has belongs_to :company
register_resource ::CompanyProfile  # has_one :company_profile on Company

Generated routes โ€‹

Plutonium prefixes nested routes with nested_ so they don't conflict with the top-level routes:

RoutePurpose
/companies/:company_id/nested_propertieshas_many index
/companies/:company_id/nested_properties/newnew
/companies/:company_id/nested_properties/:idshow
/companies/:company_id/nested_company_profilehas_one show (no :id)
/companies/:company_id/nested_company_profile/newhas_one new

For has_one:

  • Routes are singular (no :id param).
  • 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:

ruby
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:

ruby
# config/initializers/plutonium.rb
Plutonium.configure do |config|
  config.nested_association_routes = :declared   # default: :detected
end

The 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_associations renders 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:

  1. Resolves the parent via current_parent, authorized for :read?.
  2. 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
  3. Assigns the parent on create (injected into resource_params), building the record on the parent's association so a scoped association supplies its defaults.
  4. 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 โ€‹

ruby
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:

ruby
class PropertyPolicy < ResourcePolicy
  relation_scope do |relation|
    default_relation_scope(relation)    # parent when present, entity_scope otherwise
  end
end

For composite filtering on top of the default:

ruby
relation_scope do |relation|
  default_relation_scope(relation).where(archived: false)
end

URL generation โ€‹

resource_url_for(...) with the parent: option:

ruby
# 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 โ€‹

ruby
# 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:

ruby
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
end

The 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:

ruby
# 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:

ruby
class Company < ResourceRecord
  has_many :published_properties, -> { where(published: true) },
    class_name: "Property"
end

Creating 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:

ruby
class PropertiesController < ::ResourceController
  private

  def present_parent?  = true     # show on displays (default: false)
  def submit_parent?   = false    # include in forms (defaults to present_parent?)
end

Conditional โ€” show parent only when accessed standalone:

ruby
def present_parent?
  current_parent.nil?
end

Custom parent resolution โ€‹

Override current_parent for non-default lookup:

ruby
class PropertiesController < ::ResourceController
  private

  def current_parent
    @current_parent ||= Company.friendly.find(params[:company_id])
  end
end

Custom routes on nested resources โ€‹

ruby
register_resource ::Property do
  member do
    get  :analytics, as: :analytics
    post :archive,   as: :archive
  end
  collection do
    get  :report,    as: :report
  end
end

Generates /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:

ruby
class Property < ResourceRecord
  belongs_to :company
  validates :code, uniqueness: {scope: :company_id}
end

Without 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:

ruby
class Property < ResourceRecord
  scope :associated_with_organization, ->(org) {
    joins(:company).where(companies: {organization_id: org.id})
  }
end

Useful when the child is nested under a grandparent-style entity. See Entity scoping โ€บ Three model shapes.

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).

Released under the MIT License.