| name | plutonium-behavior |
| description | Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things". |
Plutonium Behavior — Controllers, Policies, Interactions
The behavior layer is intentionally thin: controllers route, policies authorize, interactions act. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to write the controller hook, policy method, or interaction class behind it.
For tenant-scoped relation_scope and entity scoping, load [[plutonium-tenancy]].
🚨 Critical (read first)
- Use generators.
pu:res:scaffold creates the base trio (controller/policy/interaction-base); pu:res:conn creates portal-specific versions. Never hand-write them.
- Don't override CRUD actions. Use hooks (
resource_params, redirect_url_after_submit, presentation hooks). Overriding create/update usually breaks authorization, params filtering, or both.
create? and read? default to false. Always override them explicitly. Derived methods (update?, show?, etc.) inherit automatically.
permitted_attributes_for_* must be explicit in production. Dev auto-detection works; production raises.
ActiveRecord::RecordInvalid is NOT rescued automatically in interactions. Always rescue when using create! / update! / save!, return failed(e.record.errors).
- Return
succeed(...) or failed(...) from execute — the controller can't tell what happened otherwise.
- Redirect is automatic on success — only use
with_redirect_response for a different destination.
relation_scope must end up calling default_relation_scope(relation) somewhere in the chain. Prefer calling it explicitly. super works when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].
- For
has_cents fields, use the virtual name (:price), not :price_cents in permitted_attributes_for_*.
- Custom action ⇒ policy method.
action :publish needs def publish? on the policy (undefined methods return false).
- Named custom routes. When adding custom routes, always pass
as: so resource_url_for can build URLs.
🛑 Before you write behavior: place it in the right layer (ASK — don't infer)
"Make X happen" doesn't say where X lives. Put it in the wrong layer and you get authorization that doesn't authorize, a 500 on the happy path, or a CRUD override that breaks params/auth. First place the requirement, then confirm names against the real code (next section):
| The requirement (in plain words) | Goes in | NOT in |
|---|
| "only <role/owner> may do X" — who is allowed | Policy def x? | a condition: proc — that only hides the button; the route stays live and callable |
| "doing X changes state / sends mail / charges a card" — the work | Interaction execute, registered as an action | a hand-written controller action; an override of create/update |
| "after create/update go to Y" · "munge a param" · "reshape the index query" | Controller hook (redirect_url_after_submit, resource_params, filtered_resource_collection) | overriding create/update/index |
| "which fields are visible / editable" | Policy permitted_attributes_for_* | the definition — that only controls how a field renders |
Then resolve the specifics:
- A custom action needs BOTH: an interaction (the work) and a policy
def <action>? (the authorization). Miss the policy method ⇒ the action silently returns false (dead button). Put the role check in condition: ⇒ it isn't enforced — a direct POST still runs.
create?/read? default to false — override explicitly; derived methods (update?/show?/…) inherit.
- Any
create!/update!/save! in execute ⇒ rescue ActiveRecord::RecordInvalid → failed(e.record.errors). Not auto-rescued — otherwise a validation failure 500s.
has_cents ⇒ permit :price, never :price_cents.
- New vs editing — never re-scaffold a controller/policy/interaction that's been customized.
Never ship a guessed role method, column, enum value, or association as applied code. user.finance?, record.status_approved?, expense.submitted_by either exist in the app or they don't — confirm them before writing, don't assume. Fall back to AskUserQuestion only for genuine product choices (what the rule should be), never for facts you can read.
✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
You have file access — inspect; don't ask the user to describe their own app.
| Check | How | Why it matters |
|---|
| File already customized | Read app/policies/<x>_policy.rb, the controller, app/interactions/* | Edit incrementally — re-scaffolding clobbers customizations |
| The role/method you authorize on exists | grep the user model for def finance? / enum :role / has_role? | user.finance? 500s (or is silently false) if absent |
| The columns/enum your interaction writes | Read the model + db/schema.rb for the enum value, approved_by/approved_at, the submitter assoc | update!(status: :approved) raises if the value/column is missing |
| Action not already wired | grep the definition for action :<x>; grep the policy for def <x>? | Avoids duplicate or dead actions |
| Cross-resource access | Use authorized_resource_scope / allowed_to?, never raw where/find | Raw queries bypass the other resource's tenancy + visibility |
Inspect with your own tools before proposing code.
🛠 Use the generator — and know what's hand-authored
| Task | How | Verify first |
|---|
| Base trio (controller + policy + interaction-base) | pu:res:scaffold | New resource |
| Portal-specific controller/policy | pu:res:conn … --dest=portal | Resource exists |
| A custom-action interaction | Hand-author in app/interactions/<name>_interaction.rb (subclass ResourceInteraction) — there is NO pu:res:interaction generator; don't invent one | — |
| Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated — re-scaffolding clobbers it |
Part 1 — Controllers
Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
Base classes
class ResourceController < ApplicationController
include Plutonium::Resource::Controller
end
class PostsController < ::ResourceController
end
What you get for free
| Action | Route | Purpose |
|---|
index | GET /posts | List with pagination, search, filters, sorting |
show | GET /posts/:id | Display single record |
new | GET /posts/new | Form |
create | POST /posts | Create |
edit | GET /posts/:id/edit | Form |
update | PATCH /posts/:id | Update |
destroy | DELETE /posts/:id | Delete |
Plus interactive-action routes for every action declared in the definition.
Where customization belongs
| Concern | Lives in |
|---|
| Field rendering (inputs, displays, columns) | Definition |
| Search, filters, scopes, sorting | Definition |
| Custom operations (publish, archive, import) | Interaction (+ action in definition) |
| Authorization rules | Policy |
| Form/show/page chrome | Definition (custom page classes) |
| 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
def preferred_action_after_submit = "edit"
def redirect_url_after_submit = posts_path
def redirect_url_after_destroy = posts_path
end
Parameter hook
def resource_params
params = super
params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
params
end
Index 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)
end
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
def submit_parent? = true
def present_scoped_entity? = true
def submit_scoped_entity? = true
Custom actions
Prefer interactive actions (definition + interaction) for anything with business logic. The only reason to hand-write a controller action is unusual flows (custom 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
end
Route must be named:
resources :posts do
member { post :publish, as: :publish }
end
Key methods
Resource access
resource_class
resource_record!
resource_record?
resource_params
current_parent
current_scoped_entity
Authorization
Current resource:
authorize_current!(record, to: :action?)
current_policy
permitted_attributes
current_authorized_scope
Other resources (cross-resource auth — use these, not raw where / find):
authorize! other_record, to: :show?
allowed_to?(:show?, other_record)
policy_for(OtherModel)
policy_for(other_record).show?
authorized_resource_scope(OtherModel)
authorized_resource_scope(OtherModel, relation: OtherModel.published)
authorized_resource_scope(OtherModel, type: :create)
authorized_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_definition
UI builders (rarely needed in controllers)
build_form
build_detail
build_collection
URL generation
resource_url_for(@post)
resource_url_for(@post, action: :edit)
resource_url_for(Post)
resource_url_for(@comment, parent: @post)
resource_url_for(Comment, action: :new, parent: @post)
resource_url_for(@post, package: AdminPortal)
resource_url_for(@post, interaction: :publish)
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
Nested resources
Routes prefixed with nested_ automatically resolve the parent:
class PostsController < ::ResourceController
end
| Method | Returns |
|---|
current_parent | Parent record |
current_nested_association | :posts |
parent_route_param | :user_id |
parent_input_param | :user |
Parent fields are excluded from forms/displays by default — toggle with the presentation hooks above. For has_one associations, routes are singular (no :id); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
Entity scoping (multi-tenancy)
When a portal calls scope_to_entity SomeModel, every controller in that portal automatically:
- Scopes queries to the entity
- Excludes the entity field from forms (detected by association class)
- Injects the entity on create/update
- Exposes
current_scoped_entity
Plutonium auto-detects which belongs_to association points to the scoped class, even when param_key differs from the association name. If a model has multiple associations to the same scoped class, you get a runtime error and must override:
class MatchesController < ::ResourceController
private
def scoped_entity_association = :home_team
end
For the full mechanics, load [[plutonium-tenancy]].
Authorization verification
After-action callbacks ensure auth was performed:
verify_authorize_current
verify_current_authorized_scope
Skip only when handling auth manually. Two forms:
class PostsController < ::ResourceController
skip_verify_authorize_current only: [:custom_action]
skip_verify_current_authorized_scope only: [:custom_action]
def custom_action
end
end
def custom_action
skip_verify_authorize_current!
skip_verify_current_authorized_scope!
end
Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
Portal-specific controllers
Portal controllers inherit from the feature-package controller if one exists (and include the portal's Concerns::Controller); otherwise from the portal's ResourceController.
class AdminPortal::PostsController < ::PostsController
include AdminPortal::Concerns::Controller
end
class AdminPortal::PostsController < AdminPortal::ResourceController
end
Non-resource portal pages (dashboard, settings) inherit from PlutoniumController:
module AdminPortal
class DashboardController < PlutoniumController
def index; end
end
end
Part 2 — Policies
Built on ActionPolicy. Plutonium adds:
- Attribute permissions (
permitted_attributes_for_*)
- Association permissions (
permitted_associations)
- Automatic entity scoping
- Derived action methods (
update? inherits from create?, etc.)
Base class
class ResourcePolicy < Plutonium::Resource::Policy
end
class PostPolicy < ResourcePolicy
def create? = user.present?
def read? = true
def permitted_attributes_for_create
%i[title content]
end
def permitted_attributes_for_read
%i[title content author created_at]
end
end
Action permissions
Must override
def create?
user.present?
end
def read?
true
end
Derived (inherit automatically)
| Method | Inherits from | Override when |
|---|
update? | create? | Different update rules |
destroy? | create? | Different delete rules |
index? | read? | Custom listing rules |
show? | read? | Record-specific read rules |
new? | create? | Rarely needed |
edit? | update? | Rarely needed |
search? | index? | Search-specific rules |
typeahead? | index? | Autocomplete-specific rules |
🚨 record is the resource CLASS on collection routes (current_policy_subject = resource_record? || resource_class). read? backs both show? (instance) and index? (class); create?/new?, export_csv?, search?, and resource-action gates (incl. kanban column actions) are class-backed too. def read? = record.published? raises NoMethodError on index — filter the list in relation_scope, gate individual records in show?. Record actions (publish? etc.) and bulk actions are always evaluated per record instance — no type guard needed.
export_csv? is the exception — it defaults to false (not derived) so CSV export is strictly opt-in. Override it to true (or index?) to enable the built-in export. The exported column set is permitted_attributes_for_export (defaults to permitted_attributes_for_index). See [[plutonium-resource]] → CSV Export.
Custom actions
Define def <action>? matching the definition's action :<action>. Undefined methods return false:
def publish? = update? && record.draft?
def archive? = create? && !record.archived?
def invite_user? = user.admin?
Bulk actions — per-record auth
def bulk_archive?
create? && !record.locked?
end
How it works:
- Policy is checked per record in the selected set.
- Backend: if any record fails, the entire request is rejected.
- UI: only actions ALL selected records support are shown (intersection).
- Records come from
current_authorized_scope — users can only select what they're allowed to access.
Attribute permissions
def permitted_attributes_for_read
%i[title content author published_at created_at]
end
def permitted_attributes_for_create
%i[title content]
end
Derived
| Method | Inherits from |
|---|
permitted_attributes_for_update | permitted_attributes_for_create |
permitted_attributes_for_index | permitted_attributes_for_read |
permitted_attributes_for_show | permitted_attributes_for_read |
permitted_attributes_for_new | permitted_attributes_for_create |
permitted_attributes_for_edit | permitted_attributes_for_update |
permitted_attributes_for_export | permitted_attributes_for_index (CSV export columns; primary key is always prepended) |
Per-action override
def permitted_attributes_for_index
%i[title author created_at]
end
def permitted_attributes_for_read
%i[title content author tags created_at]
end
🚨 Index has no record. permitted_attributes_for_index is evaluated at collection level — record is nil. permitted_attributes_for_show (and _for_read) ARE evaluated per record. So if you write a record-dependent _for_read:
def permitted_attributes_for_read
attrs = %i[title content]
attrs << :archive_reason if record.archived?
attrs
end
…you MUST also define an explicit permitted_attributes_for_index — otherwise inheritance kicks in, runs the _for_read body during the table render, and record.archived? blows up on NoMethodError: undefined method 'archived?' for nil.
def permitted_attributes_for_index
%i[title content]
end
Same rule for permitted_attributes_for_create vs _for_new (new has no persisted record).
Policy vs definition — what controls what
permitted_attributes_for_* controls which fields appear on a view. Definition field/input/display/column declarations only control how they render. A field :name in the definition does nothing unless :name is also in the relevant permitted_attributes_for_*.
Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
Anti-pattern: nested-attributes hashes
def permitted_attributes_for_create
[:name, {variants_attributes: [:id, :name, :_destroy]}]
end
Plutonium extracts nested params via the form definition, not the policy. Hash entries get iterated as field names by the form renderer and render as literal text inputs.
def permitted_attributes_for_create
[:name, :variants]
end
nested_input :variants in the definition handles the rest. See [[plutonium-resource]] › Nested Inputs.
Association permissions
def permitted_associations
%i[comments tags author]
end
Declares which associations get their own tab on the show page. When permitted_associations is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated has_many collection, has_one record, or belongs_to target. When empty, the show page renders without tabs. If permitted_attributes_for_show resolves to no fields, the empty Details tab is omitted and the first association tab leads instead.
Each named association must:
- Exist on the model (raises
ArgumentError: unknown association ... otherwise).
- Point to a class that's itself a registered Plutonium resource (raises
... is not a registered resource otherwise).
This is NOT the same as:
- Nested forms — declared with
nested_input :variants in the definition, requires accepts_nested_attributes_for on the model. See [[plutonium-resource]] › Nested Inputs.
- Association fields on tables / show details — controlled by
permitted_attributes_for_index / _for_show listing the association name.
Collection scoping (relation_scope)
Filter which records the user can see. Always compose with default_relation_scope(relation) explicitly — super is unreliable inside the block, and bypassing this triggers verify_default_relation_scope_applied!:
relation_scope do |relation|
relation = default_relation_scope(relation)
user.admin? ? relation : relation.where(author: user)
end
For tenant scoping, parent scoping, skip_default_relation_scope!, and associated_with resolution: load [[plutonium-tenancy]].
Portal-specific policies
class PostPolicy < ResourcePolicy
def create? = user.present?
end
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def destroy? = true
def permitted_attributes_for_create = %i[title content featured internal_notes]
end
class PublicPortal::PostPolicy < ::PostPolicy
include PublicPortal::ResourcePolicy
def create? = false
end
Authorization context
user
record
entity_scope
parent
parent_association
Custom context
class PostPolicy < ResourcePolicy
authorize :department, allow_nil: true
def create? = department&.allows_posting?
end
class PostsController < ResourceController
authorize :department, through: :current_department
private
def current_department = current_user.department
end
Common patterns
Block archived records
def update? = !record.try(:archived?) && super
def destroy? = !record.try(:archived?) && super
Owner-based
def update? = record.author == user || user.admin?
def destroy? = update?
Role-based
def create? = user.admin? || user.editor?
def update?
return true if user.admin?
user.editor? && record.author == user
end
Conditional attribute access
def permitted_attributes_for_create
attrs = %i[title content]
attrs += %i[featured author_id] if user.admin?
attrs
end
Part 3 — Interactions
Interactions encapsulate business logic into testable units. They're registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
Structure
class ResourceInteraction < Plutonium::Resource::Interaction
end
class PublishPostInteraction < ResourceInteraction
presents label: "Publish",
icon: Phlex::TablerIcons::Send,
description: "Make this post public"
attribute :resource
attribute :publish_date, :datetime, default: -> { Time.current }
input :publish_date
validates :publish_date, presence: true
private
def execute
resource.update!(published_at: publish_date)
succeed(resource).with_message("Post published!")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
end
Attributes
ActiveModel-style:
attribute :resource
attribute :resources
attribute :email, :string
attribute :count, :integer, default: 1
attribute :active, :boolean, default: -> { true }
attribute :tags, :array
attribute :metadata, :hash
attribute :date, :datetime
The presence of :resource / :resources / neither determines the action type — see [[plutonium-resource]] › Action Types.
Structured / repeating input
To collect a structured object or a repeating list of field-groups, use
structured_input (it declares the backing attribute for you):
structured_input :address do |f|
f.input :street
f.input :city
end
structured_input :contacts, repeat: 3 do |f|
f.input :label
f.input :phone
end
⚠️ nested_input and accepts_nested_attributes_for are NOT available on
interactions (they were model-backed). Use structured_input instead — it's
classless and collects plain hashes/arrays. See [[plutonium-resource]] ›
Structured Inputs for options (repeat:, using:, fields:).
Inputs
Same DSL as definition input (load [[plutonium-resource]] for the full list of as: types, options, dynamic blocks, etc.):
input :email
input :role, as: :select, choices: %w[admin user]
input :content, as: :text
Auto-detection rule from [[plutonium-resource]] applies here too: if the attribute type already implies the right widget, don't redeclare as:.
Presentation
presents label: "Archive Record",
icon: Phlex::TablerIcons::Archive,
description: "Move to archive"
MyInteraction.label
MyInteraction.icon
MyInteraction.description
If action :foo, interaction: FooInteraction doesn't override label:/icon:/etc., these presents values are used.
execute — outcomes
execute MUST return a succeed(...) or failed(...) outcome. Validations run automatically before execute; if they fail, the interaction short-circuits to failed().
Success
succeed(resource)
succeed(resource).with_message("Done!")
succeed(resource).with_message("Heads up!", :alert)
succeed(resource).with_redirect_response(custom_path)
succeed(resource).with_file_response(path, filename: "report.pdf")
Failure
failed("Something went wrong")
failed(resource.errors)
failed(email: "is invalid", name: "is required")
failed("Invalid value", :email)
Chaining
def execute
CreateUserInteraction.call(view_context:, **user_params)
.and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
.and_then { |r| LogActivity.call(view_context:, user: r.value) }
.with_message("User created and welcomed!")
end
The chain short-circuits on the first failure.
Validations
Standard ActiveModel — run automatically before execute:
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, inclusion: {in: %w[admin user guest]}
validate :custom_check
private
def custom_check
errors.add(:resource, "cannot be modified when archived") if resource.archived?
end
Accessing context
def execute
current_user = view_context.controller.helpers.current_user
resource.update!(updated_by: current_user)
succeed(resource)
end
A shorter current_user helper is conventional:
private
def current_user = view_context.controller.helpers.current_user
Interaction types
| Attribute pattern | Action type | Where it shows up |
|---|
attribute :resource | Record action | Show page + per-row in table |
attribute :resources | Bulk action | Bulk toolbar above table |
| neither | Resource action | Index page header |
Bulk action authorization: per-record. See [[plutonium-resource]] › Action Types and Part 2 above.
Generating interaction URLs
Use resource_url_for with the interaction: kwarg. Action type is inferred from the element and presence of ids::
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)
The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both interaction: and action: raises ArgumentError.
Complete example
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
presents label: "Invite User",
icon: Phlex::TablerIcons::UserPlus
attribute :resource
attribute :email, :string
attribute :role, :string
input :email
input :role, as: :select, choices: -> { UserInvite.roles.keys }
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
validate :not_already_invited
private
def execute
invite = UserInvite.create!(
company: resource, email: email, role: role,
invited_by: current_user
)
UserInviteMailer.invitation(invite).deliver_later
succeed(resource).with_message("Invitation sent to #{email}")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
def not_already_invited
return unless email.present?
if UserInvite.exists?(company: resource, email: email, state: :pending)
errors.add(:email, "already has a pending invitation")
end
end
def current_user = view_context.controller.helpers.current_user
end
Related Skills
- [[plutonium-resource]] — registering interactions as actions; field/input/display syntax
- [[plutonium-tenancy]] —
relation_scope, entity scoping, nested resources
- [[plutonium-ui]] — custom interaction form templates, page classes
- [[plutonium-testing]] — testing controllers, policies, interactions