0.0
Low commit activity in last 3 years
A long-lived project that still receives updates
Simple implementation of state machine for Rails using enums.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 1.6
>= 3.0

Runtime

 Project Readme

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 fromto 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 in init_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.