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

State Machine Abstraction Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. src/StateMachine/StateMachineFactoryInterface.php — the one method you actually inject: create(string $name): StateMachineInterface.
  2. src/StateMachine/StateMachineInterface.php — can(), apply(), getTransition(), getEnabledTransitions(), getState(), getName().
  3. 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.

Implementation Patterns

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.

Gotchas and Tips

  • This package alone does nothing. Plan for a second dependency (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.
  • Distinguish the two exception families. 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."
  • Symfony Workflow leaks. Symfony returns 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.
  • Property path convention is yours to define. With a custom adapter, decide once whether state is $model->state, $model->status, or a StateAware trait, and hardwire it. Mixed conventions are the most common cause of "transition succeeded but nothing changed."
  • Low-signal package. 0 stars, 0 dependents, score 0.03 — this is infrastructure used internally by Sylius, not a community-supported Laravel tool. Pin the version in 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.
  • Debugging tip. Expose 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.
  • Extension point to lean on. Implement TransitionBlockerListInterface with a tiny collector class that accumulates reasons; it gives guards a uniform shape and makes them easy to render in validation errors.
  • Name collisions with Laravel naming. 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.
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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource