Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Finite Laravel Package

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.

Deep Wiki
Context7

Getting Started

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:

  1. Finite\StateMachine\StateMachine / AbstractMachine — the public API (addState, addTransition, setObject, initialize, can, apply, getCurrentState).
  2. Finite\FiniteEvents — the event-name constants you will listen on.
  3. Finite\StateMachine\Loader\ArrayLoader — the fastest way to move machine definitions out of PHP and into configuration.
  4. 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).

Implementation Patterns

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());

Gotchas and Tips

  • 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".
  • Exactly one initial state. Without one, initialize() cannot bootstrap a fresh object; with several, which one wins is not obvious. Keep it explicit with StateInterface::TYPE_INITIAL.
  • Final states are not a hard stop you can't code around. 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.
  • Event dispatch is shared-state prone. The dispatcher attached to the machine may be reused; listeners registered for one machine can fire for another if you share a dispatcher. Either give each machine its own EventDispatcher or filter listeners on the transition name and the relevant object ($event->getStateMachine()->getObject()).
  • Listener ordering and event names. 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.
  • Exceptions, not silent failures. 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.
  • Transition names must be unique across the machine; re-adding the same name silently overwrites the previous definition. Generate them from config keys, not user input.
  • Loader schema differs between 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.
  • Debugging checklist: log $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.
  • Extension points: custom 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.
  • Version check: the 2025 release should target modern PHP and Symfony EventDispatcher; verify the 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings