teknoo/states-bundle
Teknoo States Bundle is a Symfony bundle for defining and managing object states and transitions. It provides configurable state machines, transition rules, and workflow integration for PHP applications that need clear lifecycle handling.
Reality check first: teknoo/states-bundle is a Symfony bundle — not a Laravel package — and it is archived and deprecated in favour of teknoo/states 3.1+. There is no ServiceProvider to register, no config to php artisan vendor:publish, no facade. What you can actually use day-to-day is the underlying framework-agnostic state-pattern library, installed directly:
composer require teknoo/states
Read in this order:
vendor/teknoo/states/src/*/StateInterface.php and the StatesTrait used on stated objects — this is the entire contract.CompilerPass if you want to see how state classes were auto-discovered under Symfony — you will reimplement that discovery yourself with a Laravel service provider or simply new the state objects.First use case: an Order model that only permits ship() once placed and only permits refund() once shipped — replacing a match($order->status) that is duplicated across three controllers, a job, and a model observer.
use Teknoo\States\State\StatesTrait;
use Teknoo\States\State\StatedInterface;
final class Order extends Model implements StatedInterface
{
use StatesTrait;
protected $fillable = ['status'];
}
$order->initializeStates(); // hydrate state object from the persisted `status` column
$order->ship(); // resolves to Placed state's implementation
Confirm the exact initialisation method name against the current tests — it changed between the 2.x bundle and 3.x library.
Persist the state name, hydrate the state object. The database column is a plain string (draft, placed, shipped). The state object is runtime behaviour and must never be serialised. Wrap hydration in an Eloquent cast or an observer:
protected static function booted(): void
{
static::retrieved(fn (Order $order) => $order->initializeStates());
static::saving(fn (Order $order) => $order->status = $order->getCurrentStateName());
}
One state class per state, one method per action. The State pattern's payoff is locality: if refund() only exists in ShippedState, calling it from PlacedState fails loudly at the transition boundary rather than silently in a controller. Keep state classes thin — orchestration lives in services.
Guard before mutating. Any real lifecycle has preconditions (payment captured, address verified, stock reserved). Put these in the transition guard, not the controller:
if (!$order->canShip()) {
throw new InvalidTransitionException($order->getCurrentStateName(), 'ship');
}
Register state classes in a provider, not the container magic. Symfony auto-discovered state classes via compiler pass. In Laravel, wire them explicitly:
$this->app->bind(OrderStateFactory::class, fn () => new OrderStateFactory(
placed: new PlacedState(),
shipped: new ShippedState(),
refunded: new RefundedState(),
));
Post-transition side effects go in listeners, not state methods. A refund() that also fires an email will become untestable the moment you add a third side effect. Emit a domain event from the state method and subscribe:
// in ShippedState::refund()
event(new OrderRefunded($order));
Testing: assert the transition, not the state class internals. $order->initializeStates(); $order->ship(); expect($order->getCurrentStateName())->toBe('shipped'); covers the contract you actually depend on.
Do not composer require teknoo/states-bundle. The bundle pins Symfony generations that will conflict with Laravel's Illuminate stack. Even if resolution succeeds, you inherit the full Symfony HttpKernel surface you will never use. Install teknoo/states alone.
You own the maintenance. Archived upstream means no fixes, no security patches, no PHP-8.x compatibility work. Pin the constraint explicitly and add it to your composer audit watch list. Budget for a fork if a future PHP minor breaks it.
State classes and Eloquent hydration fight each other. If you serialise the state object into a json column or a queue payload, you will deserialise a stale object graph. Persist the name, rebuild on retrieved(). The same trap bites SerializesModels on queued jobs — rehydrate after ModelsRestored.
Generated code drift. If you later evaluate the successor teknoo/states 3.1+ and use its generator, treat output as a build artefact: gitignore it, regenerate in CI, and add a header banner. Any committed generated class gets edited by hand within a month and fights the next regeneration in a review diff.
Transition discovery is not free. The library resolves which state a method belongs to at call time, so a typo in a state-class method name surfaces as a runtime "method not found" rather than a static error. Add a test that iterates every state class and asserts its public methods against the transition table, or you will discover gaps in production.
Where the pattern breaks down. Below roughly five states with fewer than two guards, a PHP 8.1 backed enum with match() is shorter, statically analysable, and framework-native. Reach for the state pattern when states carry genuinely different behaviour (different methods, different validation rules), not when they are just labels — otherwise you pay class proliferation for nothing.
Integration with Laravel-native alternatives. If the archived dependency is a blocker, spatie/laravel-model-states provides the same shape with active maintenance and first-class Eloquent/transition/attribute integration. The migration cost from a hand-wired teknoo/states setup is low because both express the same pattern — the difference is where the container plumbing lives.
How can I help you explore Laravel packages today?