yohang/finite
yohang/finite is a lightweight PHP finite state machine library for modeling workflows and state transitions. Define states, transitions, guards, and callbacks with a fluent API, and use it in Laravel or any PHP application.
Install the package and its event dispatcher dependency through Composer:
composer require yohang/finite symfony/event-dispatcher
The core entry point is Finite\StateMachine\StateMachine. A minimal machine needs three things: states, transitions, and an object whose state it manages.
use Finite\StateMachine\StateMachine;
use Finite\State\StateInterface;
$machine = new StateMachine();
$machine
->addState('draft', StateInterface::TYPE_INITIAL)
->addState('review')
->addState('published', StateInterface::TYPE_FINAL)
->addTransition('submit', 'draft', 'review')
->addTransition('publish', 'review', 'published');
$machine->setObject($post); // any object you want to track
$machine->initialize(); // syncs state, applies the initial state if unset
if ($machine->can('submit')) {
$machine->apply('submit'); // draft -> review
}
echo $machine->getCurrentState()->getName(); // "review"
Where to look first, in order:
Finite\StateMachine\StateMachine / AbstractMachine — the public API (addState, addTransition, setObject, initialize, can, apply, getCurrentState).Finite\FiniteEvents — the event-name constants you will listen on.Finite\StateMachine\Loader\ArrayLoader — the fastest way to move machine definitions out of PHP and into configuration.Finite\StateMachine\Accessor\* — how Finite reads/writes the state on your object. This is the part you will most likely customise.The canonical first use case is a content or order lifecycle: draft -> review -> published, driven from a controller or service, with can() used to decide which action buttons/endpoints are legal. Finite is deliberately persistence-agnostic — it computes and stores state on your object, but saving to the database is your job (see the POST_TRANSITION listener pattern below).
Define machines from config, not inline. Inline addState/addTransition chains are fine for tests, but production machines belong in a loader config so they can be reviewed and reused:
use Finite\StateMachine\Loader\ArrayLoader;
$config = [
'states' => [
'draft' => ['type' => \Finite\State\StateInterface::TYPE_INITIAL],
'review' => ['type' => \Finite\State\StateInterface::TYPE_NORMAL],
'published' => ['type' => \Finite\State\StateInterface::TYPE_FINAL],
],
'transitions' => [
'submit' => ['from' => 'draft', 'to' => 'review'],
'publish' => ['from' => 'review', 'to' => 'published'],
],
];
(new ArrayLoader())->load($machine, $config);
Wrap this in a small factory (PostStateMachineFactory::create()) so controllers never assemble machines themselves. On Symfony, the separate yohang/finite-bundle wires a configured finite.state_machine service under a finite: config key; if you don't want the bundle, the SymfonyLoader variant reads the same shape via Symfony Config.
Control persistence through events. The single most important day-to-day pattern is hooking the machine to your ORM:
use Finite\Event\TransitionEvent;
use Finite\FiniteEvents;
$machine->getDispatcher()->addListener(
FiniteEvents::POST_TRANSITION,
function (TransitionEvent $event) use ($entityManager) {
$entityManager->flush(); // entity already carries the new state here
}
);
Because Finite writes the state onto the object before post-transition listeners fire, a flush inside POST_TRANSITION (or POST_STATE_CHANGE) persists consistently. Don't flush in PRE_TRANSITION — the state hasn't changed yet.
Persist the state name, not the State object. Finite's accessors write a Finite\State\State instance onto your object. For Doctrine or any storage, implement a small custom AccessorInterface (or use MethodAccessor with getState()/setState() that convert to/from the string name). Keeping State objects out of your entity's mapped fields avoids serialization and schema surprises and keeps $post->getState() returning a plain string.
Guards: check with can(), enforce with exceptions. apply() throws Finite\Exception\TransitionException when the transition is illegal from the current state, and Finite\Exception\StateException for state-level problems. In controllers, translate those into 409 Responses; in services, use can() up front and let the exception be the backstop.
Drive UI from the machine, not from hard-coded status checks. For a toolbar, iterate your transition names and render a button when $machine->can($name) is true. getCurrentState() gives you name, type, and any properties you attach to states in config (e.g. ['properties' => ['visible' => true]]), which is a clean way to expose flags like "editable" without duplicating the state graph in templates.
One machine instance per object, per request. The state machine is stateful (setObject + initialize). Build it in the service/controller method that uses it, or clone/reset it between objects. Never share a single machine across entities in a loop without calling setObject() and initialize() again.
Testing. Construct the machine with new StateMachine() and hand-define states/transitions in the test — you get hermetic, fast tests of the graph without touching the loader or DB:
$machine->setObject(new Post());
$machine->initialize();
$this->assertTrue($machine->can('submit'));
$this->assertFalse($machine->can('publish')); // illegal from draft
$machine->apply('submit');
$this->assertSame('review', $machine->getCurrentState()->getName());
initialize() is mandatory. Calling apply() or getCurrentState() before setObject() + initialize() gives you wrong or null state. initialize() reads the object's current state and only assigns the initial state when none is set — so it doubles as "hydrate from DB".initialize() cannot bootstrap a fresh object; with several, which one wins is not obvious. Keep it explicit with StateInterface::TYPE_INITIAL.can() simply returns false for transitions whose source doesn't match; nothing throws until you call apply(). Don't assume the machine guards data integrity — that's your domain layer.State objects leak into your entity by default. If you skip the custom accessor, $post->state becomes a State instance, which surprises Doctrine mappings, JSON serialization, and var_dump logs. Decide early and convert to a string at the boundary.EventDispatcher or filter listeners on the transition name and the relevant object ($event->getStateMachine()->getObject()).PRE_TRANSITION fires before the state is written, POST_TRANSITION after; state-change events (PRE_STATE_CHANGE/POST_STATE_CHANGE) bracket the assignment separately. Confirm semantics against Finite\FiniteEvents in the installed version — don't assume a post-transition listener sees a persisted entity.apply() on an illegal transition throws TransitionException; wrap it where users can click stale buttons (two tabs, back-button resubmits) and return a friendly "action no longer available" response.ArrayLoader and the Symfony/Bundle loader. Port the shape from the README examples matching your installed version; callback entries (before/after with on and do) are the part most likely to drift between versions.$machine->getCurrentState()->getName(), the transition name, and the result of can() before apply(). That triple explains virtually every "impossible state" bug. Then check the accessor — if getState() returns something that isn't a State object where the machine expects one, everything downstream is wrong.AccessorInterface (storage format), custom Transition/State subclasses (behaviour on transition), event listeners (side effects, notifications, audit logs), and a factory for wiring config. Prefer accessors + listeners over subclassing the machine itself.symfony/event-dispatcher constraint against your framework version before adding the package to a legacy app, and pin the Finite version in composer.json because the graph-config API has historically been the volatile part.How can I help you explore Laravel packages today?