piece/stagehand-fsm
Stagehand FSM is a Laravel/PHP finite state machine package for defining states, transitions, guards, and actions. Model workflows and object lifecycles with clean, testable state-driven logic.
The repository link is missing and the last tagged release is from January 2018, so treat the package source itself as the documentation. Start by reading composer.json (for PHP/Laravel constraints), then src/ and tests/ — the tests are usually the most accurate usage guide for a package this age.
Install and inspect what you actually got:
composer require piece/stagehand-fsm
# then read the installed source rather than guessing at the API
ls vendor/piece/stagehand-fsm/src
sed -n '1,200p' vendor/piece/stagehand-fsm/README.md
If the package ships a service provider, Laravel 5.5+ auto-discovery may register it. If not, add it manually to config/app.php. Check for a publishable config:
php artisan vendor:publish --tag=stagehand
Pick one narrow workflow for the first use case: an Order moving through pending → paid → shipped → cancelled. Model it before touching invoices, refunds, or inventory. The value of an FSM is that illegal transitions become impossible to represent, so start with the smallest lifecycle that has real rules.
Verify the core verbs before designing around them. A typical FSM package exposes something like: define states/transitions, attach a trait to an Eloquent model, check whether a transition is allowed, and apply it. Confirm the real method names in src/ — the examples below are illustrative, not copied from a README.
// config/stagehand.php — illustrative shape, verify keys against the package
return [
'machines' => [
'order' => [
'states' => ['pending', 'paid', 'shipped', 'cancelled'],
'transitions' => [
'pay' => ['from' => 'pending', 'to' => 'paid'],
'ship' => ['from' => 'paid', 'to' => 'shipped'],
'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
],
],
],
];
class Order extends Model
{
use \Piece\StagehandFsm\Traits\HasStateMachine; // confirm trait name
protected $stateMachine = 'order';
protected $stateColumn = 'status';
}
$order->transition('pay'); // or $order->stateMachine()->apply('pay')
$order->canTransition('ship'); // confirm availability
Keep the transition map declarative and central. Put states and transitions in one config file or one dedicated class per domain, not scattered across controllers. When a new engineer asks "what can an order do?", there should be exactly one file to open.
Use the FSM as a routing layer, not a business-logic container. Guards should answer "is this transition legal right now?" (payment captured, stock reserved). The work that happens because of the transition — sending mail, writing ledger rows, queuing jobs — belongs in listeners or a small service that wraps the transition.
DB::transaction(function () use ($order) {
$order->transition('ship');
ShipmentCreated::dispatch($order);
});
Authorize before transitioning. Map transitions to policies or gates so a controller never calls transition() on an arbitrary user-supplied string. Enumerate the allowed transitions for the current state and render only those buttons; reject everything else server-side.
$this->authorize('transition', [$order, $request->input('transition')]);
Persist an audit trail. The current state column tells you where a record is, not how it got there. Add a state_transitions table (model, from, to, transition name, actor, timestamp) written in the same transaction as the state change. This is the single most useful thing you can add on day one; it pays for itself the first time support asks "why is this order cancelled?"
Test the transition matrix, not just happy paths. Write one test per legal transition and one per illegal pair. Better: a data-provider test that iterates every state/transition combination so adding a state without a test fails loudly.
/** @test */
public function a_shipped_order_cannot_be_cancelled()
{
$order = Order::factory()->create(['status' => 'shipped']);
$this->expectException(\Exception::class);
$order->transition('cancel');
}
Support more than one machine per model when lifecycles genuinely differ. Payment status and fulfillment status are separate concerns with separate rules; forcing them into one state column produces a combinatorial mess. Use a named machine per concern if the package supports it, otherwise use separate columns and separate machine definitions.
Wrap transitions in jobs for slow side effects. If a transition triggers external calls, transition synchronously, commit, then dispatch. This keeps the FSM fast and avoids holding a database transaction open during an HTTP request to a shipping provider.
composer.json constraints; on PHP 8.1+/Laravel 10+ you may need --ignore-platform-reqs, a fork, or a maintained alternative. Budget for reading and possibly patching the source.lockForUpdate() inside a transaction, or an optimistic where status = ? update and check the affected-row count.false as "not allowed"; others require an exception. Read the guard implementation once and standardize on one style across the codebase.transition=ship, validate it against the machine's allowed transitions for the current state rather than trusting it.src/: guard/condition callbacks, pre/post-transition hooks, custom state storage, and any event dispatcher integration. These are where you add auditing, logging, and notifications without forking.from, to, transition name, and the guard result at debug level. When a transition is mysteriously rejected, the guard — not the state map — is almost always the cause.How can I help you explore Laravel packages today?