All posts

Rails

Real Approval Workflows in Rails: The Use Cases That Made Me Build Signoff

Most non-trivial Rails applications eventually grow the same awkward corner: some record that shouldn’t just be created, edited, or deleted. It needs a human to look at it and say “yes” or “no” before anything important happens.

The first time you need this, you add a status column and a few before_save checks. By the third or fourth time, you have duplicated state machines, comments living in random notes text fields, and an audit table that someone built in a hurry and that nobody trusts anymore when finance or legal comes asking questions.

I got tired of writing that code again and again. The result is Signoff, a small, convention-over-configuration gem that adds declarative approval workflows to any ActiveRecord model.

class ExpenseReport < ApplicationRecord
  include Signoff

  signoff do
    state :draft
    state :manager_review
    state :finance_review
    state :approved
    state :rejected

    transition :draft,          to: :manager_review
    transition :manager_review, to: :finance_review
    transition :finance_review, to: :approved

    reject_to :rejected
  end
end

You get submit!, approve!, reject!, authorization guards, after_transition hooks for notifications, clean query scopes, and, most importantly, an immutable PostgreSQL JSONB audit trail that actually holds up when someone asks “who approved this and what did they say?”

This post is not the reference manual. It is the list of real situations where the gem has already paid for itself, with the exact DSL and integration patterns I reach for each time.

Why a dedicated gem instead of “just another state column”?

Because the hard parts are never the column. The hard parts are:

  • Making sure two people can’t both approve the same thing at the same millisecond.
  • Producing a tamper-resistant log that contains who, when, from what IP, with a free-text comment and arbitrary metadata.
  • Keeping the “who is allowed to act right now” rules in one obvious place instead of spread across policies, pundit, and controller filters.
  • Having pending?, approved?, and in_state(:finance_review) scopes that stay fast at 500k rows without N+1 or subqueries.
  • Wiring reliable notifications that only fire after the state and the audit row are durably committed.

Signoff puts all of that behind a tiny DSL and two generators.

Use case 1: Expense reports and reimbursements (the canonical example)

This is the one I ship first on almost every internal tool.

States usually look like:

signoff do
  state :draft
  state :manager_review
  state :finance_review
  state :approved
  state :rejected

  transition :draft,          to: :manager_review
  transition :manager_review, to: :finance_review
  transition :finance_review, to: :approved

  reject_to :rejected

  allow_transition :manager_review do |user|
    user.manager? || user.id == record.submitter_id # allow self-review for small amounts sometimes
  end

  allow_transition :finance_review do |user, record|
    user.finance_team? && record.amount_cents <= user.approval_limit_cents
  end

  after_transition do |record, event|
    ExpenseMailer.with(report: record, event: event).notify_next_step.deliver_later
  end
end

In the UI you show different buttons based on can_approve? and can_reject? (they never raise; they just return false when the guard would block).

The audit trail here is gold for reimbursement disputes and for the annual audit. Every approve! call with a comment becomes a row you can never edit or delete by default.

Use case 2: Purchase orders and invoice approvals (multi-stage money movement)

Purchase orders often need more eyes than expense reports. A typical flow I have seen:

draft → procurement_review → budget_owner_approval → finance_release → ordered (or paid)

With Signoff you model the “approval phase” cleanly even if the rest of the PO lifecycle (supplier sync, receiving, etc.) lives in other states or separate models.

signoff do
  state :draft
  state :procurement_review
  state :budget_owner_review
  state :finance_release
  state :ordered
  state :rejected

  transition :draft,              to: :procurement_review
  transition :procurement_review, to: :budget_owner_review
  transition :budget_owner_review, to: :finance_release
  transition :finance_release,    to: :ordered

  reject_to :rejected

  allow_transition :procurement_review  do |user| user.procurement? end
  allow_transition :budget_owner_review do |user, po| user.budget_owner_for?(po.department) end
  allow_transition :finance_release     do |user| user.can_release_funds? end
end

The metadata field on events is perfect here. On the finance_release step you can do:

po.approve!(
  user: current_user,
  comment: "Released against Q3 marketing budget",
  metadata: {
    approved_amount_cents: po.total_cents,
    budget_line: "marketing-q3-2026",
    po_number_assigned: "PO-2026-0842"
  }
)

Later you can query across events:

Signoff::Event
  .where(action: "approve", to_state: "finance_release")
  .where("metadata->>'budget_line' = ?", "marketing-q3-2026")

All without adding columns to the purchase_orders table.

Use case 3: HR leave, time-off, and PTO requests

HR workflows are full of nuance: manager approval, sometimes HRBP review for long leave, automatic calendar block only on final approval, and a very strong requirement for an audit log (“we need to prove this person was approved for those dates”).

The DSL becomes almost identical to expenses:

signoff do
  state :requested
  state :manager_approval
  state :hr_review
  state :approved
  state :rejected

  transition :requested,       to: :manager_approval
  transition :manager_approval, to: :hr_review
  transition :hr_review,       to: :approved

  reject_to :rejected
end

Because the state lives in a plain column on the time_off_requests table, you can still use all your normal Rails associations and scopes for “show me everyone’s approved leave this month” while the approval flow itself is handled by the gem.

The last_approval and approved_by helpers make it trivial to render “Approved by Priya Sharma on June 12” everywhere the request is displayed.

Use case 4: Content publishing and document approval pipelines

Marketing sites, internal knowledge bases, and regulated documents (policies, contracts, security reviews) often need an editorial + legal + final-publish flow.

One pattern that works well:

signoff do
  state :draft
  state :editorial_review
  state :legal_review
  state :approved_for_publish
  state :published
  state :rejected

  transition :draft,            to: :editorial_review
  transition :editorial_review, to: :legal_review
  transition :legal_review,     to: :approved_for_publish
  transition :approved_for_publish, to: :published

  reject_to :rejected

  after_transition do |doc, event|
    case event.to_state.to_s
    when "legal_review"
      LegalReviewJob.perform_later(doc.id)
    when "published"
      doc.publish! # your own method that actually flips a public boolean or copies to CDN
    end
  end
end

Notice that “published” is a workflow terminal state. You can still have a separate published_at or visibility column on the model if you want; the workflow state just tells you how it got there.

For documents that can be “scheduled”, some teams add an extra state or treat the final approve! as the trigger that sets a publish job for later.

Use case 5: Ops and change-control sign-offs

Internal platforms that manage production changes (feature flag flips, database migrations that need DBA sign-off, firewall rule changes, access grants) are another sweet spot.

These flows tend to value two things above all:

  1. A bulletproof record of who approved what and when.
  2. The ability to require different roles at different stages (on-call engineer vs. security vs. VP).
signoff do
  state :proposed
  state :oncall_review
  state :security_review
  state :exec_approval
  state :executed
  state :rolled_back
  state :rejected

  transition :proposed,      to: :oncall_review
  transition :oncall_review, to: :security_review
  transition :security_review, to: :exec_approval
  transition :exec_approval, to: :executed

  reject_to :rejected
end

Because the gem takes a row-level lock (SELECT ... FOR UPDATE) and re-validates the current state inside the transition transaction, you get safe concurrency even when two on-call engineers are looking at the same change request.

Use case 6: Support ticket escalation and resolution sign-off

High-severity or customer-impacting tickets sometimes require a second pair of eyes before they are closed.

signoff do
  state :open
  state :in_progress
  state :awaiting_signoff
  state :resolved
  state :rejected   # reopened or needs more work

  transition :open,            to: :in_progress
  transition :in_progress,     to: :awaiting_signoff
  transition :awaiting_signoff, to: :resolved

  reject_to :rejected   # "needs more work" path
end

The comment on the final approve event becomes the official resolution note. The entire thread of decisions is queryable without having to reconstruct it from ticket comments or activity feeds.

What the audit trail actually buys you

The signoff_events table (JSONB + GIN index on metadata) is deliberately boring and append-only. That is the point.

Every event records:

  • action (“submit”, “approve”, “reject”)
  • from_state / to_state
  • user_id
  • comment
  • metadata (jsonb)
  • ip_address and user_agent (when you turn the config on)
  • created_at

Because it is polymorphic (workflowable), the same events table serves every model in your app that includes Signoff.

Common queries I actually write:

  • “Show me every approval that touched the marketing budget line this quarter”
  • “Who has the most rejections on expense reports in the last 90 days?”
  • “List all records currently waiting on finance_review for department X”

The scopes on the model (pending, approved, in_state) stay fast because the current state lives in an indexed column on the owning table, not in the events table.

A few honest limitations

Signoff is deliberately opinionated:

  • It wants PostgreSQL (the audit trail is a jsonb column + GIN index).
  • It is designed for linear-to-slightly-branching human approval flows. Extremely graph-like or parallel approval processes are outside its sweet spot.
  • It gives you one “reject sink” state by design (reject_to). Most real processes only need one place that means “this was denied or sent back.”

If your workflow is closer to a full BPMN engine or requires arbitrary parallel branches and complex and/or conditions, you will probably outgrow it. For the 80% of cases where “a few humans need to look at this thing in sequence and we must never lose the record of who said what,” it has been extremely effective.

How to include it in your Rails app

The generators get you most of the way there in under a minute.

bundle add signoff
rails generate signoff:install          # creates the events table + initializer
rails generate signoff:model ExpenseReport
rails db:migrate

Then wire up one model (the DSL parts are shown in the use cases above):

# app/models/expense_report.rb
class ExpenseReport < ApplicationRecord
  include Signoff

  signoff do
    # ... your states, transitions, allow_transition, after_transition ...
  end
end

Drop this into ApplicationController once. It populates Signoff::Current.user (and optionally IP/user-agent) from your existing current_user so you rarely have to pass user: manually:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Signoff::Controller
end

Routes

# config/routes.rb
resources :expense_reports do
  member do
    patch :submit
    patch :approve
    patch :reject
  end
end

Controller actions + error handling

# app/controllers/expense_reports_controller.rb
class ExpenseReportsController < ApplicationController
  before_action :set_report, only: %i[show submit approve reject]

  rescue_from Signoff::UnauthorizedError,
              Signoff::InvalidTransitionError do |error|
    redirect_back fallback_location: expense_reports_path, alert: error.message
  end

  def submit
    @report.submit!(comment: params[:comment])
    redirect_to @report, notice: "Submitted for review."
  end

  def approve
    @report.approve!(comment: params[:comment])   # user comes from Signoff::Current
    redirect_to @report, notice: "Approved."
  end

  def reject
    @report.reject!(comment: params[:comment])
    redirect_to @report, notice: "Rejected."
  end

  private

  def set_report
    @report = ExpenseReport.find(params[:id])
  end
end

Views (the important bits)

<%# app/views/expense_reports/show.html.erb %>
<p>
  Status: <strong><%= @report.current_state.to_s.humanize %></strong>
  <% if @report.approved? %><% elsif @report.rejected? %><% end %>
</p>

<% if @report.draft? %>
  <%= button_to "Submit for review", submit_expense_report_path(@report), method: :patch %>
<% end %>

<% if @report.can_approve?(current_user) %>
  <%= button_to "Approve", approve_expense_report_path(@report), method: :patch %>
<% end %>

<% if @report.can_reject?(current_user) %>
  <%= button_to "Reject", reject_expense_report_path(@report), method: :patch %>
<% end %>

<h3>Audit trail</h3>
<% @report.workflow_history.each do |event| %>
  <div>
    <%= event.created_at.to_fs(:short) %><strong><%= event.action %></strong>
    <%= event.from_state %><%= event.to_state %>
    by <%= event.user&.name || "system" %>
    <% if event.comment.present? %><%= event.comment %><% end %>
  </div>
<% end %>

You can also be explicit and pass the user directly:

@report.approve!(user: current_user, comment: "Approved after checking receipts")

Notifications with ActiveJob + ActionMailer

The cleanest place for side effects is inside the signoff block using after_transition. It runs after the database transaction commits, so the Signoff::Event is already persisted when your job runs.

# in the model
signoff do
  # ... states and transitions ...

  after_transition do |record, event|
    WorkflowNotificationJob.perform_later(record.id, event.id)
  end
end
# app/jobs/workflow_notification_job.rb
class WorkflowNotificationJob < ApplicationJob
  queue_as :default

  def perform(record_id, event_id)
    event  = Signoff::Event.find(event_id)
    record = event.workflowable

    case event.action
    when "submit"
      ApprovalMailer.with(report: record, event: event).submitted.deliver_later
    when "approve"
      ApprovalMailer.with(report: record, event: event).advanced.deliver_later
    when "reject"
      ApprovalMailer.with(report: record, event: event).rejected.deliver_later
    end
  end
end
# app/mailers/approval_mailer.rb
class ApprovalMailer < ApplicationMailer
  def submitted
    @report = params[:report]
    mail(to: User.managers.pluck(:email),
         subject: "New item awaiting your review")
  end

  def advanced
    @report = params[:report]
    mail(to: User.finance_team.pluck(:email),
         subject: "Ready for the next approval step")
  end

  def rejected
    @report = params[:report]
    @event  = params[:event]
    mail(to: @report.submitter.email,
         subject: "Your request was rejected")
  end
end

Dashboard / listings using the built-in scopes

One of the nicest parts of Signoff is the scopes it adds. You get pending, approved, rejected, and in_state(...) for free, and they are backed by an indexed column so they stay fast.

Expand the controller with an index:

# app/controllers/expense_reports_controller.rb
def index
  @pending  = ExpenseReport.pending.order(created_at: :desc)
  @approved = ExpenseReport.approved.limit(20)
  @rejected = ExpenseReport.rejected
end

And a simple dashboard view:

<%# app/views/expense_reports/index.html.erb %>
<h2>Awaiting action (<%= @pending.size %>)</h2>
<%= render partial: "expense_report", collection: @pending %>

<h2>Recently approved</h2>
<%= render partial: "expense_report", collection: @approved %>

<h2>Rejected</h2>
<%= render partial: "expense_report", collection: @rejected %>

You can chain them like any other scope:

ExpenseReport.where(department: "marketing").pending
ExpenseReport.in_state(:finance_review, :manager_review)

Preload the audit trail when you need it to avoid N+1s:

ExpenseReport.includes(signoff_events: :user).find(params[:id])

The repo contains a complete runnable example under examples/expense_approval/ (full controller, views, job, mailer, seeds, and a demo script) if you want to see everything wired together in one place.

Where to go from here

If you’ve built approval flows by hand (or with AASM, state_machines, or an external service) and have war stories about the audit table that always ends up lying or the race condition that let two people approve the same PO, I’d love to hear them.

The gem is still young (0.1.0 as of this writing). The core loop (declare states, drive transitions, trust the log) has already removed a surprising amount of repetitive and error-prone code from the projects where I’ve used it.

If any of the situations above sound like problems you are solving right now, give it a try. The generators get you to a working flow in minutes; the hard part is usually just deciding what the states should actually be called.

Share this post
Written by Jijo Bose All posts