Wizard DSL β
Experimental
Wizards are experimental β the DSL and behavior may change in a future release.
A wizard is a Ruby class β class X < Plutonium::Wizard::Base. It declares ordered steps, an optional terminal review step, wizard-level options, and an execute commit hook. This page is the full reference for the author-facing DSL.
For task-oriented walkthroughs, start with the Wizards guide.
π¨ Critical β
- Use bang methods (
create!/update!/save!) inon_submitandexecute. Failure is signalled by a raised exception β a non-bangfalsereturn advances the wizard and silently loses data. condition:lambdas must be nil-safe. They run against the typeddatasnapshot at every transition, including before their deciding step is filled (nil).reviewmust be the last step. Declaring a step afterreviewraises at load time.using:targets a model only β not an interaction, not a bare definition.executereturns an Outcome βsucceed(...)/failed(...), or raise to fail.
Wizard-level macros β
| Macro | Meaning |
|---|---|
presents label:, icon:, description: | The launch button's label + icon (same as interactions), plus an optional description: rendered as the wizard's header subheading. |
navigation :linear | :free | Stepper jump policy. :linear (default) β back to any visited step; :free β any visible visited step. Forward jumps to unvisited steps are never allowed. |
stepper false | Hide the top rail (the step indicator). On by default. |
on_relaunch :new | Controls a bare relaunch of a tokened wizard when the user has pending (in-progress) runs. Default :prompt shows a "resume or start new" chooser instead of silently forking; :new opts out and always mints a fresh run. No-op for keyed/anonymous wizards (they already auto-resume their single run). See Anchoring & resume. |
anchored with: Model / anchored via: :method | Run against an existing record; read via anchor. with: resolves from the URL :id (resource-mounted); via: resolves by calling a controller method (portal-level, context-anchored). See Anchoring & resume. |
cleanup_after <ttl> | :never | Idle TTL before the abandonment sweep reaps a session and rolls back its tracked records. Defaults to config.wizards.cleanup_after. :never opts out. |
concurrency_key { β¦ } / concurrency_key :method | Key a run by the returned value(s) (records β GID, scalars β string, arrays serialized element-wise β structured, not flat-joined; the tenant is folded in automatically). The keyed in_progress row is the lock β a second launch at the same key resumes, never forks. Omit β unlimited concurrent wizard_token-keyed runs β except an anchored wizard, which defaults to { [anchor, current_user] } (one draft per user per record). See Anchoring & resume. |
one_time | Retain the completed row at the concurrency_key (blocks restart, gate-able). Requires a concurrency_key. Omit β row deleted on completion (repeatable). See One-time wizards. |
completed do |wizard| β¦ end | Custom body for the "already completed" page a finished one-time wizard shows when re-opened (replaces the default confirmation). See completed below and One-time wizards. |
encrypt_data | Encrypt the staged data column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires active_record.encryption keys β see Storage & config. |
anonymous | Opt into guest (unauthenticated) access β the wizard runs pre-login (auth is required otherwise). The guest's identity is a server-minted run-id in the Rails session; it crosses the auth boundary only at its terminal execute. Mount it public: true (the default for anonymous). Mutually exclusive with concurrency_key/one_time β a guest is already session-keyed and repeatable, so declaring both raises (whichever is declared last). See Authentication. |
class CompanyOnboardingWizard < Plutonium::Wizard::Base
presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
navigation :linear
# ... steps ...
endstep β
step(key, label: nil, description: nil, condition: nil, using: nil, **using_opts, &block)A step is one screen. The block declares its fields with the existing field DSL (attribute/input/validates/structured_input/form_layout) and may attach the per-step hooks on_submit/on_rollback.
step :company, label: "Company details", condition: -> { data.plan.kind == "business" } do
attribute :name, :string
attribute :subdomain, :string
input :name
input :subdomain
validates :name, :subdomain, presence: true
form_layout do
section :identity, :name, :subdomain, label: "Identity", columns: 2
end
end| Option | Meaning |
|---|---|
label: | The step's display label (stepper + heading). Defaults to key.to_s.humanize. |
description: | Optional sub-label rendered under the step heading. |
condition: | Lambda over data (and anchor) gating inclusion. Subtractive branching. Must be nil-safe. |
using: | Import a field surface from a model. See below. |
The block is optional only when using: supplies everything.
Fields inside a step β
A step's block is the same field DSL used on definitions and interactions:
attribute :name, :typeβ declares a typed attribute (feeds thedatasnapshot).input :name, as:, ...β how the field renders.validates :name, ...β ActiveModel validations, run on Next. These also drive the form's field affordances exactly like a resource form: apresencevalidation renders the required marker (*), andlength/numericality/format/inclusionfeedmaxlength/min/max/pattern/auto-choices. Validations imported viausing:surface these too.structured_input :name, repeat: N do |f| ... endβ a repeatable/structured group βdata.<step>.nameis an array of typed sub-objects. The sub-fields can come from the block (above), or from a model viausing:/fields:(same selectors as a step'susing:) instead of a block.form_layout do ... endβ section the step's fields (section,columns:,collapsible:, etc.), scoped to this step.
See plutonium-resource βΊ Definition for the full field/input/layout vocabulary.
using: a model β
using: imports field declarations from an ActiveRecord model so a step needn't re-declare them. It is a step option, not a block method (avoids Ruby's Module#using refinements clash), and it targets a model only.
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
step :details, label: "Details", using: Company, only: %i[tagline] do
attribute :referral_code, :string # plus a wizard-local field
input :referral_code
endWhat gets imported:
| Source | Imported |
|---|---|
Model.attribute_names / attribute_types | The field universe + cast types. |
<Model>Definition (auto-resolved) | Input styling (as:, options, labels). Best-effort β no definition is fine. |
Transient Model.new(slice).valid? | Validations, keeping errors on imported fields + :base. |
<Model>Definition#form_layout | Section layout, filtered to imported fields. |
| Selector / flag | Effect |
|---|---|
fields: (alias only:) | Import only these attributes. |
except: | Import everything except these. |
validate: false | Skip validation reuse (write your own inline validates). |
layout: false | Skip inherited form_layout (default single grid). |
validation_context: | Run valid?(context) for context-scoped model validations. |
Declaration reuse only β using: never pulls in the model's persistence or callbacks. Data stages into data; your execute/on_submit does the writes.
Why a model, not a definition
A Plutonium::Resource::Definition carries no link to its model β the controller binds them at request time. The only reliable direction is model β definition, so the model is the reuse target, and <Model>Definition is auto-resolved from it for styling.
Attachment fields β
A file field is a :string attribute (it holds the upload token, not the bytes) plus a file input (as: :file, :uppy, or :attachment):
step :photo, label: "Photo" do
attribute :photo, :string
input :photo, as: :file # server-side staged (default)
# input :photo, as: :uppy, direct_upload: true, endpoint: "/upload" # direct upload
enddata is JSON staged across requests, so a file can't ride along β only its backend token (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data). execute assigns the token to the model's attachment natively (model.photo.attach(data.photo.photo) for AS, model.update!(photo: data.photo.photo) for active_shrine). The review summary and the input preview (on Back/resume) resolve the token to a displayable attachment automatically.
| Declare | Behaviour | |
|---|---|---|
| Server-side (default) | as: :file | file submitted with the step; the wizard uploads it to the backend cache while staging. AS and active_shrine. |
| Direct upload | as: :uppy, direct_upload: true, endpoint: | browser uploads to the endpoint, posts a token (async UI). |
| Backend (server-side) | backend: :active_storage / :shrine | defaults to config.wizards.attachment_backend (auto-detects active_shrine, else AS). Must match the model execute assigns to. |
| Uploader (Shrine only) | uploader: PhotoUploader | cache the file through a specific Shrine uploader (its cache-stage plugins β mime/dimension extraction, generate_location, processing β run instead of base Shrine's). The minted token stays uploader-agnostic, so display + promotion are unaffected. Accepts a class or a class-name string; raises for the AS backend. Server-side staging only (direct upload configures the uploader at its endpoint). Its validations are enforced on the step β see the note below. |
| Multiple | array attribute + multiple: true | staged value is an array of tokens. |
Uploader validations are enforced on the step
A Shrine file field is validated on its step against the field's effective uploader β its uploader: if given, else base Shrine (whichever carries the Attacher.validate rules). A file that violates them is rejected right there (a field error + re-render), exactly like a validates β not deferred to execute. Mechanically: Uploader.upload caches the file (running no validations), then the step's validation pass runs the attacher's validations against the staged token. This needs Shrine's optional validation/validation_helpers plugin β without it there's nothing to enforce and it's a clean no-op. ActiveStorage fields are likewise unaffected.
Per-step hooks β
execute is the default commit point (atomic, at the end). Per-step on_submit is opt-in save-as-you-go β use it only when a real record must exist mid-flow.
on_submit β
Runs in its own transaction when the step completes (after its fields validate). Inside it:
persist record(or a list) β register record(s) the engine tracks for resume + cleanup βpersisted[:step_key].fail!("message")β abort with a base (form-level) error.fail!(:field, "message")β abort with a field-level error.
on_submit do
charge = PaymentApi.authorize!(anchor, data.billing.card_token)
fail!("Card was declined") unless charge.ok?
persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
endThe wizard never advances past a failed on_submit. Earlier committed steps are untouched (undo them via Cancel β cleanup).
on_rollback β
Rollback happens on Cancel, abandonment-sweep, or when this step becomes branch-hidden (a later answer flips its condition: false, so save-as-you-go records it created would otherwise be orphaned). On any of these, the engine always destroys every persist'd record in reverse step order via destroy! (which respects a model's own soft-delete/paranoia override). When a step is pruned this way its data / persisted / visited state is also cleared, so re-entering that branch re-runs on_submit from scratch.
on_rollback is an optional, ADDITIONAL compensating block for side effects the engine can't see: refunding a charge, calling an external API, deleting something persist didn't track. It reads persisted[...] and runs before the engine's destroy (records still alive), in addition to it, never instead of it. Don't destroy the persist'd record yourself in the block; the engine does that.
# The engine destroys persisted[:billing] for you; this just refunds the charge.
on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }Supply an on_rollback when abandonment must do more than drop the record(s) (refund a charge, call an external API), or when on_submit registered no record at all (side-effect-only steps, whose on_rollback still runs). To keep a partial record rather than destroy it, make the model itself soft-delete (so its destroy! detaches) or use cleanup_after :never.
review β
review(label: "Review", description: nil, condition: nil, summary: true, header: true, &block)The built-in terminal step. Must be last. It lists outstanding (invalid/unvisited) steps as jump links and gates Finish until all visible steps are valid. It declares no fields of its own.
What it renders depends on completion state and the summary: / block options:
| State | Body |
|---|---|
| Incomplete (a visible step is invalid/unvisited) | The outstanding "fix this" links + the auto-summary of what's entered so far (the review-and-fix view). |
Complete, summary: true (default) | The auto-summary of every visible step; the custom block, if any, renders below it. |
Complete, summary: false, with a block | The custom block replaces the summary (author owns the body). |
Complete, summary: false, no block | A built-in "ready to complete" confirmation panel. |
| Option | Meaning |
|---|---|
label: | The review step's label (default "Review"). |
description: | Optional sub-label under the review heading. |
summary: | Show the auto-summary of completed steps (default true). When false, the complete-state body is your block β or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
header: | Show the step-header section (the label plus the "check everything over" prompt, which only appears when the summary is shown) above the body (default true). false drops it for a chromeless finish. |
The auto-summary renders each field through the display pipeline, honoring the input's declared as:/label::
- a choice input (
select/radio_buttonswithchoices:) resolves the stored value back to its label ("pro"β"Pro") using the same choice mapper the form uses, so the recap matches what the user picked; - an
as: :currencyinput formats the value as currency ("1500.5"β"$1,500.50") rather than echoing a bare decimal β passunit:on the input (the data snapshot has nohas_centsreflection to infer it from).
review label: "Review & submit" # auto-summary + gated finish
review label: "Review & submit" do |wizard| # custom content BELOW the summary
"By submitting you agree to the #{wizard.data.plan.plan} plan terms."
end
review summary: false, header: false # fully chromeless β "ready to complete" panelTIP
stepper false (a wizard-level macro) + review summary: false, header: false + no block gives a fully chromeless flow (no rail, no header, no summary), ending on the built-in "ready to complete" panel.
The custom block's render context β
The block runs in the Phlex view context (self is the rendering component), not the controller β that's what lets it emit markup. So you can:
- return a String (the simplest case) β it renders as the block's text;
- emit Phlex directly β
div,span,plain,render SomeComponent.new(...); - reach view / route helpers via
helpers.*(e.g.helpers.link_to,helpers.current_user, a path helper).
The block is yielded the wizard, so wizard.data, wizard.anchor, wizard.persisted, and wizard.current_user are all in hand.
review label: "Review & submit" do |wizard|
div(class: "text-sm") do
plain "Billing to "
strong { wizard.data.company.name }
plain " β "
plain helpers.link_to("see our terms", helpers.terms_path)
end
endDon't mix styles in one block: Phlex emits a returned String in addition to anything you wrote with div/render, so returning a String after emitting markup double-renders it. Pick one.
completed β
completed do |wizard|
# β¦Phlex bodyβ¦
endA custom body for the "already completed" page β what a finished one-time wizard shows when a user re-opens it. On completion a one-time wizard retains its row but clears the data, so there's nothing to review; re-entry renders this standalone page instead of re-running the flow. Only meaningful for one-time wizards (repeatable ones leave no completed row, so re-launching just starts fresh).
Without completed, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block replaces that body entirely β you supply your own content (and your own way out):
class WelcomeWizard < Plutonium::Wizard::Base
concurrency_key { current_user }
one_time
# β¦stepsβ¦
completed do |wizard|
h1 { "You're all set up!" }
a(href: "/dashboard") { "Go to your dashboard" }
end
endThe block runs in the same Phlex view context as the review block (self is the component; reach helpers via helpers.*) and is yielded the wizard. The same don't-mix-styles caveat applies.
execute β
The at-end commit hook, run once after the last visible step, in one transaction.
def execute
company = Company.create!(name: data.company.name, subdomain: data.company.subdomain)
succeed(company).with_message("You're all set!")
end- Returns a
succeed(value)/failed(errors)Outcome (the same Outcome interactions use β.with_message,.with_redirect_response, etc. all work). - Use bang methods so a failure raises. The engine catches
ActiveRecord::RecordInvalid(β field errors) andPlutonium::Wizard::StepError(β base error viafail!); any other error re-raises as a 500. - On success the wizard marks the session completed, clears
data/persisted, and redirects (PRG) so a back-button replay can't re-runexecute.
Entry authorization β authorize? β
A portal-level (standalone) wizard has no resource policy, so gate entry by defining an authorize? instance method. The controller checks it before each request; a falsy return β ActionPolicy::Unauthorized (403).
def authorize?
current_user.present? && !current_user.onboarded?
endAs-built: authorize? is an instance method
Define def authorize? on the wizard. (Resource-attached wizards instead use their action's policy predicate β see Registration & launch.)
Accessors β
Available inside steps, condition:, on_submit, on_rollback, and execute:
| Accessor | Returns |
|---|---|
data | Typed, dot-accessible snapshot of everything entered so far, step-keyed β read a field through its owning step: data.<step>.<field>. Read-only; not-yet-collected fields read as nil or their default:. Each step has its own sub-object, so two steps may declare the same field name without colliding. |
data.<step>.<field> | The cast value (real Boolean/Integer/Date, not raw string). data.<step>.<structured> β array of typed sub-objects. An unknown step key reads as nil. |
anchor | The record the wizard was launched against. Raises NotAnchoredError if the wizard isn't anchored. |
persisted[:step_key] | Record(s) a per-step on_submit registered via persist. Lazily rehydrated on first access (located from stored GlobalIDs the first time you read the key, memoized thereafter). |
Outcome helpers β
| Helper | Use |
|---|---|
succeed(value = nil) | Success outcome (alias success). Chain .with_message(...), .with_redirect_response(...). |
failed(errors = nil, attribute = :base) | Failure outcome. Accepts a string, an array, a hash ({field => msg}), or an errors object. |
fail!("message") / fail!(:field, "message") | Raise a StepError from on_submit/execute (sugar over raise). |
Errors β
| Error | Raised when |
|---|---|
Plutonium::Wizard::NotAnchoredError | anchor called on a non-anchored wizard (also raised when a via: anchor resolves to nil or the wrong type). |
Plutonium::Wizard::StepError | Raised by fail! (or directly) for a custom, non-AR step failure β maps to a form error. |
Plutonium::Wizard::UnknownWizardError | A mount's wizard_class doesn't resolve to a loaded Plutonium::Wizard::Base subclass β a misconfigured mount or a tampered route param. |
