kphoen/doctrine-state-machine-bundle
Symfony bundle that integrates the Doctrine finite state machine library, letting you define state machines and transitions in configuration and expose them as container services for clean, testable workflow logic in your PHP applications.
This is a Symfony bundle, not a Laravel package. Before writing any code, internalize what that means:
laravel-doctrine/orm) or, more realistically, port the idea to Eloquent.First steps if you are evaluating it:
DependencyInjection/Configuration.php to understand the config shape (states, transitions, callbacks).composer.json constraints. The package predates modern Symfony/PHP versions and will likely conflict with current dependencies.First meaningful use case: an Order entity with pending → paid → shipped → cancelled transitions. You want guards (canTransitionTo), side effects on transition (email, audit log), and persistence of the new state.
Minimal Laravel-shaped setup:
composer require laravel-doctrine/orm # if you truly need Doctrine
# do NOT expect `composer require kphoen/doctrine-state-machine-bundle` to "just work"
// app/Providers/StateMachineServiceProvider.php
public function register(): void
{
// Manually construct whatever factory/registry the bundle exposes.
// You will not get Symfony's bundle bootstrapping or config merging.
}
If you only need the behavior (and you almost certainly do), skip the bundle and implement the pattern against Eloquent — see below.
| Symfony bundle concept | Laravel equivalent |
|---|---|
| Bundle + Extension | Service provider + config/state_machines.php |
| DI service definitions | Container bindings in register() |
| YAML/XML config | PHP config array |
| Doctrine entity listener/subscriber | Eloquent observer or explicit service call |
| Symfony event dispatcher | Laravel events/listeners |
| Container compiler pass | Manual wiring or a small factory |
The bundle assumes a Symfony container. In Laravel you must build the registry yourself:
public function register(): void
{
$this->app->singleton(StateMachineRegistry::class, function ($app) {
return new StateMachineRegistry(config('state_machines'));
});
}
Keep the bundle's factory/config objects isolated behind your own interface so you can swap them for a hand-rolled implementation later.
Do not try to reuse the bundle's YAML loader. Store definitions in config/state_machines.php:
return [
'order' => [
'property' => 'status',
'states' => ['pending', 'paid', 'shipped', 'cancelled'],
'transitions' => [
'pay' => ['from' => ['pending'], 'to' => 'paid'],
'ship' => ['from' => ['paid'], 'to' => 'shipped'],
'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
],
],
];
If using Doctrine entities in Laravel, keep the state property private and expose intent-revealing methods. Never let controllers set the status field directly.
class Order
{
private string $status = 'pending';
public function pay(): void
{
$this->applyTransition('pay');
}
private function applyTransition(string $name): void
{
// delegate to the configured machine; throw on invalid transition
}
}
For Eloquent, this becomes a trait:
trait HasStateMachine
{
public function canTransitionTo(string $transition): bool { /* ... */ }
public function transitionTo(string $transition): void
{
DB::transaction(function () use ($transition) {
if (! $this->canTransitionTo($transition)) {
throw new InvalidTransition($transition);
}
$this->{config('state_machines.'.$this->stateMachineKey().'.property')}
= $this->nextState($transition);
$this->save();
event(new StateTransitioned($this, $transition));
});
}
}
State machines are only useful if transitions and side effects are atomic. Wrap the transition, the persistence, and any domain event dispatch in a single transaction. Use optimistic locking (updated_at check or a version column) to prevent two concurrent requests from both transitioning pending → paid.
The bundle supports callbacks around transitions. In Laravel, prefer events/listeners for side effects (email, audit) and reserve synchronous guards for invariants (e.g., "cannot ship an unpaid order"). This keeps the state machine pure and testable.
from/to pair and every invalid pair.Given the deprecation, the practical "day-to-day leverage" is:
symfony/workflow standalone (it does not require the full framework) if you want a maintained FSM library.extra.laravel provider entry. You must register everything manually; php artisan vendor:publish will not work.config/state_machines.php yourself at boot, or malformed definitions will fail at runtime instead of deploy time.DB::transaction() does not cover Doctrine's unit of work. A rollback in one will not roll back the other. Keep state transitions entirely on one side.pending and both write paid. Add a version column or a conditional UPDATE ... WHERE status = ?.EventServiceProvider and test they actually fire.php artisan tinker), log every attempted transition with from/to/actor, and assert canTransitionTo() in tests before calling transitionTo().symfony/workflow standalone. Shipping the bundle itself into a Laravel app is high effort, high risk, and low long-term value.How can I help you explore Laravel packages today?