sylius/state-machine-abstraction
Sylius State Machine Abstraction provides a lightweight PHP interface for state machines, decoupling state and transition logic from any specific implementation. It defines the contracts needed to build or integrate state machines into Sylius and other PHP applications.
composer require sylius/state-machine-abstraction
This is a contracts-only package: it ships a handful of plain PHP interfaces and exceptions, nothing runnable. You will not get a working state machine by installing it — you get a stable vocabulary (StateMachineInterface, StateMachineFactoryInterface, TransitionInterface, TransitionBlockerInterface, TransitionBlockerListInterface) that you back with a real implementation.
First files to read, in order:
src/StateMachine/StateMachineFactoryInterface.php — the one method you actually inject: create(string $name): StateMachineInterface.src/StateMachine/StateMachineInterface.php — can(), apply(), getTransition(), getEnabledTransitions(), getState(), getName().src/StateMachine/TransitionBlockerListInterface.php — the guard contract; an empty list means "allowed."You need a backend implementation. In the Sylius ecosystem this is typically winzou/state-machine or the Symfony Workflow component, both of which you will wrap in a thin adapter that implements StateMachineInterface. There is no Laravel bridge, no service provider, no config file — you build those.
First use case to try: an order lifecycle.
$factory = app(StateMachineFactoryInterface::class);
$machine = $factory->create('order');
if ($machine->can('pay', $order)) {
$machine->apply('pay', $order); // pending -> paid
}
Bind the factory in a service provider first, then exercise it from php artisan tinker before touching HTTP layers.
Service provider binding. Register the factory as a singleton and resolve configurations from your own config file:
// app/Providers/StateMachineServiceProvider.php
public function register(): void
{
$this->app->singleton(StateMachineFactoryInterface::class, fn ($app) =>
new MyStateMachineFactory($app['config']->get('state-machines'))
);
}
Config-driven machines. Keep definitions data-only so workflows are testable in isolation:
// config/state-machines.php
return [
'order' => [
'class' => App\Models\Order::class,
'property_path' => 'state',
'transitions' => [
'pay' => ['from' => ['pending'], 'to' => 'paid'],
'ship' => ['from' => ['paid'], 'to' => 'shipped'],
'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
],
],
];
Eloquent integration. The machine is stateless; the state lives on the row. Add a state string column, cast as needed, and do not put transitions into the model — keep them in config so you can see the whole graph in one file. This mirrors Sylius's StateAwareTrait convention of exposing a plain $state property.
Guarding with blockers. Return a non-empty TransitionBlockerListInterface from a callback to block a transition, with a human-readable reason:
if (! $order->isPaidInFull()) {
$blockers->add('Order not paid in full');
}
Use it for business rules (can't ship unpaid order, can't cancel shipped order), never for authorization — auth belongs in policies/middleware executed before you call can().
Transactional side effects. Wrap apply() in DB::transaction() and dispatch Laravel events from a decorator rather than scattering listener logic across controllers:
class EventfulStateMachine implements StateMachineInterface
{
public function __construct(private StateMachineInterface $inner) {}
public function apply($transition, $object): void
{
$this->inner->apply($transition, $object);
event(new TransitionApplied($this->inner->getName(), (string) $transition, $object));
}
// delegate other methods...
}
Multiple workflows. create('order'), create('payment'), create('refund') act as your registry. Keep each named machine single-responsibility; a "big order machine" with 14 states is a smell.
Testing. Because everything is stateless, unit tests are trivial: construct the machine, pass a plain object, assert getState() and can() results. No database, no container.
symfony/workflow or winzou/state-machine). If you install and find only interfaces, that is expected — nothing is broken.$object is untyped in the interfaces. Your adapter must validate it against the configured class. A wrong object will fail confusingly inside the concrete implementation, not at the boundary.TransitionBlockerListInterface is not a boolean. can() returning true requires an empty list; a null or malformed list will make every transition look blocked. Log blocker messages when can() is false — this is the single best debugging move.TransitionNotFoundException (typo'd transition name, misconfiguration) is a developer error; TransitionFailedException (blocked or guard rejected) is a business state. Catch them separately; conflating them hides config bugs behind "it was blocked."Marking, Transition, and Definition objects. Wrap them in TransitionInterface implementations and never expose Symfony\Component\Workflow\* types from your adapter — otherwise swapping backends means touching every caller.$model->state, $model->status, or a StateAware trait, and hardwire it. Mixed conventions are the most common cause of "transition succeeded but nothing changed."composer.json, and wrap it behind your own StateMachine facade so you can swap to spatie/laravel-model-states or a hand-written enum-based machine without rewriting call sites.getEnabledTransitions($object) in an admin view — you get a live "what can happen next" panel for free, and it is the fastest way to confirm config is loaded correctly.TransitionBlockerListInterface with a tiny collector class that accumulates reasons; it gives guards a uniform shape and makes them easy to render in validation errors.apply() in this package is not the same as Bus::dispatch() or queued jobs — keep it synchronous and side-effect-free inside the transition itself; queue anything expensive from the dispatched event.How can I help you explore Laravel packages today?