Controller โ
Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin โ when in doubt, push the change to the definition (UI) or the policy (auth).
๐จ Critical โ
- Don't override CRUD actions. Use hooks (
resource_params,redirect_url_after_submit, presentation hooks). Overridingcreate/updateusually breaks authorization, params filtering, or both. - Named custom routes only. Always pass
as:โ without it,resource_url_forcan't build URLs (critical for nested resources). - Authorization is verified after every action โ if you write a custom action, you MUST call
authorize_current!yourself or useskip_verify_authorize_current/skip_verify_authorize_current!. - Cross-resource queries: use
authorized_resource_scope(OtherModel), not rawwhere. Otherwise you bypass that resource's tenancy and visibility rules.
Base classes โ
# app/controllers/resource_controller.rb โ installed once
class ResourceController < ApplicationController
include Plutonium::Resource::Controller
end
# app/controllers/posts_controller.rb โ per resource, generated
class PostsController < ::ResourceController
# Empty โ all CRUD inherited
endPortal-specific overrides:
# packages/admin_portal/app/controllers/admin_portal/resource_controller.rb
module AdminPortal
class ResourceController < ::ResourceController
include AdminPortal::Concerns::Controller
end
end
# packages/admin_portal/app/controllers/admin_portal/posts_controller.rb
class AdminPortal::PostsController < ResourceController
# Portal-specific customizations
endWhat you get for free โ
| Action | Method | Path | Purpose |
|---|---|---|---|
index | GET | /posts | List with pagination, search, filters, sorting |
show | GET | /posts/:id | Display a single record |
new | GET | /posts/new | Form |
create | POST | /posts | Create |
edit | GET | /posts/:id/edit | Form |
update | PATCH/PUT | /posts/:id | Update |
destroy | DELETE | /posts/:id | Delete |
Plus interactive-action routes for every action declared in the definition (/posts/:id/record_actions/publish, etc.).
Where customization belongs โ
| Concern | Lives in |
|---|---|
| Field rendering (inputs, displays, columns) | Definition |
| Search, filters, scopes, sorting | Query |
| Custom operations (publish, archive, import) | Interaction + action on definition |
| Authorization rules | Policy |
| Form / show / page chrome | Definition (custom page classes โ see UI โบ Pages) |
| Custom redirect logic | Controller hook |
| Param munging | Controller hook |
| Custom index query shape | Controller hook |
| Presentation of parent/entity fields | Controller hook |
Override hooks โ
All hooks are private methods. Override only the ones you need.
Redirect hooks โ
class PostsController < ::ResourceController
private
# Where to go after create/update: "show" (default), "edit", "new", "index"
def preferred_action_after_submit = "edit"
# Custom URL after create/update (overrides preferred_action_after_submit)
def redirect_url_after_submit = posts_path
# Custom URL after destroy
def redirect_url_after_destroy = posts_path
endParameter hook โ
def resource_params
params = super
params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
params
endIndex query hook โ
def filtered_resource_collection
base = current_authorized_scope
base = base.featured if params[:featured]
current_query_object.apply(base, raw_resource_query_params)
endAlso where you eager-load associations the index renders. super keeps scoping, search, filters and sorting:
def filtered_resource_collection = super.includes(:author, :category)Presentation hooks โ
Control whether parent / scoped-entity fields appear in forms and displays. Defaults are false (hidden, since they're inferred from the URL/portal).
def present_parent? = true # show parent field on displays
def submit_parent? = true # include in forms (defaults to tracking present_parent?)
def present_scoped_entity? = true
def submit_scoped_entity? = trueConditional pattern โ show parent only when accessed standalone:
def present_parent?
current_parent.nil?
endLifecycle callbacks โ
Standard Rails callbacks work:
class PostsController < ::ResourceController
before_action :check_quota, only: [:create]
private
def check_quota
if current_user.posts.count >= 100
redirect_to resource_url_for(resource_class), alert: "Post limit reached"
end
end
endCustom actions โ
Prefer interactive actions (definition + interaction โ see Resource โบ Actions) for anything with business logic. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
class PostsController < ::ResourceController
def publish
authorize_current!(resource_record!, to: :publish?)
resource_record!.update!(published: true)
redirect_to resource_url_for(resource_record!), notice: "Published!"
end
endRoute must be named:
register_resource Post do
member { post :publish, as: :publish } # โ `as:` required
endAlways name custom routes
Without as:, resource_url_for can't build the URL โ particularly critical for nested resources.
Key methods โ
Resource access โ
resource_class # The model class
resource_record! # Current record (raises RecordNotFound if not found)
resource_record? # Current record (nil if not found)
resource_params # Permitted params for create/update
current_parent # Parent record for nested routes
current_scoped_entity # Tenant entity for the current portal (nil if not scoped)Authorization โ
Current resource:
authorize_current!(record, to: :action?) # check permission, raises if denied
current_policy # Policy instance for current resource
permitted_attributes # Allowed attributes for the current action
current_authorized_scope # Scoped collection the user can accessOther resources โ cross-resource auth. Use these, NOT raw where / find:
authorize! other_record, to: :show? # ActionPolicy โ raises if denied
allowed_to?(:show?, other_record) # Boolean check
policy_for(OtherModel) # Policy instance for class or record
policy_for(other_record).show?
authorized_resource_scope(OtherModel) # Scope on the model class
authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a relation
authorized_resource_scope(OtherModel, type: :create) # Different actionauthorized_resource_scope applies the other resource's relation_scope AND the current policy context (entity scope, etc.). Always prefer it over OtherModel.all / raw where in cross-resource controller code โ otherwise you bypass that resource's tenancy and visibility rules.
Definition access โ
current_definitionUI builders (rarely needed in controllers) โ
build_form
build_detail
build_collectionURL generation โ
resource_url_for(@post) # show URL
resource_url_for(@post, action: :edit)
resource_url_for(Post) # index URL
resource_url_for(Post, action: :new)
# Nested
resource_url_for(@comment, parent: @post)
resource_url_for(Comment, action: :new, parent: @post)
# Cross-package
resource_url_for(@post, package: AdminPortal)
# Interactive actions
resource_url_for(@post, interaction: :publish)
resource_url_for(Post, interaction: :import)
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
resource_url_for(@post, parent: @user, interaction: :publish)Nested resources โ
Routes prefixed nested_ automatically resolve the parent. See Tenancy โบ Nested resources for the full surface; the controller-side methods:
current_parent # parent record
current_parent_class # User
current_nested_association # :posts
parent_input_param # :userThe nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and current_parent_class / current_nested_association read it back. (There is no parent_route_param โ the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
Parent fields are excluded from forms/displays by default. Toggle with the presentation hooks.
Custom parent resolution:
def current_parent
@current_parent ||= Company.friendly.find(params[:company_id])
endEntity scoping (multi-tenancy) โ
When a portal calls scope_to_entity Organization, strategy: :path, controllers in that portal automatically:
- Scope queries to the entity
- Exclude the entity field from forms (detected by association class)
- Inject the entity on create/update
- Expose
current_scoped_entity
Plutonium auto-detects which belongs_to association points to the scoped class, even when param_key differs from the association name:
# Portal config
scope_to_entity Competition::Team, param_key: :team
# Model โ association name differs from param_key, but Plutonium finds by class
class Match < ApplicationRecord
belongs_to :competition_team
endMultiple associations to the same class โ
If a model has two associations pointing at the scoped entity class, Plutonium raises:
Match has multiple associations to Competition::Team: home_team, away_team.
Plutonium cannot auto-detect which one to use for entity scoping.
Override `scoped_entity_association` in your controller to specify the association.Override:
class MatchesController < ::ResourceController
private
def scoped_entity_association = :home_team
endFull mechanics in Tenancy โบ Entity scoping.
Authorization verification โ
After-action callbacks ensure authorization happened:
verify_authorize_current # all actions โ `authorize_current!` must have been called
verify_current_authorized_scope # all except :new and :create โ scope must have been loadedSkip only when handling auth manually. Two forms:
# Class-level โ across multiple actions
class PostsController < ::ResourceController
skip_verify_authorize_current only: [:preview]
skip_verify_current_authorized_scope only: [:preview]
def preview
# auth handled manually
end
end
# Per-action โ bang methods, inside the action body
def preview
skip_verify_authorize_current!
skip_verify_current_authorized_scope!
# auth handled manually
endPrefer the per-action bang form when only one action skips โ keeps the exception co-located with the code that needs it.
Response formats โ
Controllers respond to:
- HTML (default)
- JSON (via RABL templates)
- Turbo Stream (for Hotwire)
Error handling โ
class PostsController < ::ResourceController
rescue_from ActiveRecord::RecordNotFound do
redirect_to resource_url_for(resource_class), alert: "Post not found"
end
rescue_from ActionPolicy::Unauthorized do
redirect_to resource_url_for(resource_class), alert: "Not authorized"
end
endSpecifying resource class โ
The resource class is inferred from the controller name. Override if needed:
class LegacyPostsController < ::ResourceController
controller_for Post
endPortal-specific controllers โ
Each portal can override:
class AdminPortal::PostsController < ResourceController
private
def preferred_action_after_submit = "index"
endSee App โบ Portals for the full portal controller story.
Related โ
- Policies โ authorization called from controllers
- Interactions โ business logic for custom actions
- Resource โบ Definition โ UI config (where most "controller-like" tweaks belong)
- Resource โบ Actions โ registering interactive actions
- Tenancy โบ Nested resources โ parent/child routing
