First, confirm the package is installable in your Laravel version. It is archived and has no Laravel provider, so treat it as a low-level domain component, not a framework feature.
composer require teknoo/states-life-cycle
Then inspect, in this order:
vendor/teknoo/states-life-cycle/README.mdvendor/teknoo/states-life-cycle/src/vendor/teknoo/states-life-cycle/tests/vendor/teknoo/states/ for the base Stateable / State contracts and traitsThe tests are especially important because the package is archived and examples may lag behind the actual API.
A good first use case is a single Eloquent model with a small lifecycle, such as an Order:
draft -> pending -> paid -> fulfilled
\-> cancelled
Persist only the state name in the database, for example orders.state, and keep the package state objects as transient domain objects. Start by wrapping the package service in one application-level adapter:
namespace App\Services;
use App\Models\Order;
final class OrderLifecycle
{
public function __construct(private readonly object $lifeCycle)
{
}
public function transition(Order $order, string $to): Order
{
// Adapt to the installed package API: hydrate/attach state,
// invoke the lifecycle transition, then persist the scalar state.
$this->lifeCycle->transition($order, $to);
$order->state = $to;
$order->save();
return $order;
}
}
Register the adapter in a service provider and keep controllers/actions dependent on OrderLifecycle, not on package classes directly.
Use the package as the transition engine, but keep Laravel orchestration around it.
State classes per domain state
Create a small class for each state and keep behavior close to the state:
namespace App\States\Order;
final class Paid
{
public function canCancel(): bool
{
return true;
}
}
If the package expects state registration or mapping, centralize it in a config file or dedicated factory rather than scattering class names through controllers.
Persist scalars, rehydrate objects
Store state as a string or enum-backed column. Do not serialize package state objects into the database.
$order = Order::whereKey($id)->lockForUpdate()->firstOrFail();
$orderLifecycle->transition($order, 'paid');
Use a model observer or saving guard to prevent accidental direct writes to state from forms, seeders, or ad-hoc code. The lifecycle adapter should be the normal write path.
Wrap transitions in transactions
DB::transaction(function () use ($order, $orderLifecycle) {
$orderLifecycle->transition($order, 'paid');
DB::afterCommit(fn () => event(new OrderPaid($order->id)));
});
Keep side effects such as mail, webhooks, and queue dispatches after commit. If the package’s automatic behavior performs side effects synchronously, make those behaviors thin and queue the slow work.
Use actions instead of controllers
final class PayOrder
{
public function __construct(private readonly OrderLifecycle $lifeCycle)
{
}
public function __invoke(Order $order): Order
{
return DB::transaction(fn () => $this->lifeCycle->transition($order, 'paid'));
}
}
Controllers, form requests, jobs, and Artisan commands should all call the same action. That keeps HTTP, queue, and CLI behavior consistent.
Expose state to authorization and UI
Use policies/gates for “can this user cancel this order?” and expose allowed transitions as a small read model:
final class OrderStatePresenter
{
public function allowedTransitions(Order $order): array
{
return match ($order->state) {
'pending' => ['paid', 'cancelled'],
'paid' => ['fulfilled', 'cancelled'],
default => [],
};
}
}
Integrate with Laravel events carefully
Use Laravel events for application integration, but do not duplicate the package’s lifecycle events. Pick one source of truth for “state changed” and document it. If the package emits events, map them into Laravel events in one adapter.
Test the graph, not the package internals
Unit-test state transitions, invalid transitions, and adapter behavior. Feature-test the HTTP action. Use RefreshDatabase and assert both the persisted state and that side effects were queued or fired after commit.
Archived package: It has no active maintainer, no Laravel integration, and zero dependents. Pin the version, isolate it behind an adapter, and be prepared to replace it with a custom state machine or Symfony Workflow.
Composer compatibility: Check PHP and dependency constraints before adoption:
composer why-not teknoo/states-life-cycle
composer show teknoo/states-life-cycle
No auto-discovery: There is no Laravel service provider to discover. Bind the package service or your adapter manually in AppServiceProvider.
Do not bind lifecycle managers as singletons blindly: Lifecycle services may hold or cache state. Prefer scoped() rather than singleton() if the service is stateful, especially with Laravel Octane and queue workers.
Concurrency: Two requests can try to transition the same order. Use lockForUpdate() or an optimistic version column, then re-check the current state inside the transaction.
Invalid transitions: Catch package-specific exceptions and map them to HTTP 409 Conflict or a validation error. Do not let them become generic 500s.
Partial writes: If automatic lifecycle behavior throws after some writes, the transaction protects the database but not external side effects. Keep external side effects after commit or use an outbox pattern.
Recursive or nested transitions: If state entry/exit behavior triggers another transition, guard against recursion and define whether nested transitions are allowed.
Direct state writes: Eloquent mass assignment, seeders, imports, and admin panels can bypass the lifecycle. Protect the state attribute and document the only supported write path.
Event duplication: Laravel model events plus package lifecycle events can fire twice for the same change. Centralize state-change notifications in the adapter.
Long-running automatic behavior: If the package runs behavior synchronously on transition, move mail, webhooks, PDF generation, and third-party calls into queued jobs.
No config quirks to rely on: Expect manual wiring. If the installed version uses reflection or annotations for discovery, run composer dump-autoload after moving state classes and clear Laravel caches during deployment.
Migration risk: Because the package is archived, define your own domain-level interface early. That interface is the seam that lets you replace the package later without rewriting controllers or jobs.
How can I help you explore Laravel packages today?