RailsMachine
A small state machine for ActiveRecord models, built on top of Rails enums.
Installation
Add to your Gemfile:
gem 'rails_machine'
Requires Ruby >= 3.0 and ActiveRecord >= 7.0.
Usage
Add an integer column to your model to hold the state:
class AddStateToVehicles < ActiveRecord::Migration[7.0]
def change
add_column :vehicles, :state, :integer, default: 0, null: false
end
end
Include RailsMachine and declare the machine:
class Vehicle < ActiveRecord::Base
include RailsMachine
rails_machine do
state :stopped
state :idling
state :driving
state :broken
init_state :stopped
transition from: :stopped, to: :idling
transition from: :idling, to: :stopped
transition from: :idling, to: :driving
transition from: :driving, to: :idling
transition from: :any, to: :broken
transition from: :broken, to: :stopped, guards: [->(v) { v.inspected? }]
end
end
States
States are stored as Rails enums. IDs start at 0 and increment by 1, or you can set them explicitly:
state :stopped # id 0
state :idling # id 1
state :archived, id: 99 # id 99
state :legacy # id 100
You get all the usual enum helpers for free:
vehicle.stopped? # => true
vehicle.idling! # persists the transition
Vehicle.stopped # scope
Vehicle.states # { "stopped" => 0, "idling" => 1, ... }
Defining the same state name twice raises ArgumentError.
Init states
init_state declares which states a record is allowed to start in. Records saved with any other initial value fail validation.
init_state :stopped
init_state :broken # multiple allowed
If no init_state is declared, any state is accepted on creation.
Transitions
Each transition defines a legal from → to pair. Attempting an undeclared transition raises ActiveRecord::RecordInvalid on save (or returns false from valid?).
:any is a wildcard:
transition from: :any, to: :broken # any state can become :broken
transition from: :broken, to: :any # :broken can become anything
transition from: :any, to: :any # no restrictions
Guards
Guards are callables that receive the record and must return truthy for the transition to pass:
transition from: :broken, to: :stopped, guards: [->(v) { v.inspected? }]
If every guard on every matching transition returns falsy, validation adds a :guard_failed error.
Custom column
Pass column: to use something other than :state:
rails_machine column: :status do
state :draft
state :published
transition from: :draft, to: :published
end
Error messages
Validation failures add errors on the state column with these keys:
-
:invalid_init_state— record was created in a state not listed ininit_state -
:transition_not_found— no transition defined for the attempted state change -
:guard_failed— a matching transition exists but its guard(s) returned falsy
Default English messages ship with the gem. Override them under en.errors.messages in your own locale files.
License
MIT.