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

States Laravel Package

teknoo/states

Teknoo States provides a simple, extensible finite state machine implementation for PHP. Model objects with states and transitions, define allowed changes, and trigger callbacks on entry, exit, or transition events—useful for workflows, order processing, and status management.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and gate-check first. composer require teknoo/states — one runtime dependency (teknoo/immutable), no service provider, no config publish, no facade. The only hard prerequisite is PHP ^8.4. If your Laravel deployment target is 8.2/8.3, stop here: this package would be the forcing function for a platform upgrade, which is a platform decision, not a library decision.

Where to look first, in order:

  1. demo/ — runnable scripts (php demo/demo_article.php) that show the whole pattern end-to-end in ~50 lines. Fastest way to absorb the idiom.
  2. documentation/howto/ — walkthroughs of state declaration, attributes, and automated switching.
  3. tests/States/Functional — real stated classes, no mocks; these are the de facto API contract.
  4. src/Proxy/ProxyTrait and src/State/StateTrait only when you need to debug dispatch or visibility resolution.

Your first use case should be small and already painful. Pick one domain object with 3–5 real states and a couple of methods whose bodies change per state — typically an Order (pending → paid → shipped → cancelled) with pay(), ship(), cancel(). Not a greenfield model; pick the one whose current switch ladder is already a maintenance hotspot.

Minimal skeleton. The stated class declares states with the #[StateClass] attribute (the old statesListDeclaration() is deprecated and fires E_USER_DEPRECATED). Each state is a class extending AbstractState, and each "state method" is a builder that returns a Closure — that is the one non-negotiable idiom:

#[StateClass(OrderPending::class)]
#[StateClass(OrderPaid::class)]
class Order extends Model
{
    public function pay(): static
    {
        return $this->states()->pay(); // dispatches to the currently enabled state
    }
}

Inside a state class, pay() returns a closure that is rebound to the proxy (the Order), so $this, self, and static inside it refer to the order, not to the state instance. That rebinding is what makes state methods behave like real methods across states.


Implementation Patterns

One state class per state, kept small. This is the entire payoff and the biggest failure mode. A 400-line state class recreates the god-object with extra indirection. A state class should read like the single case block you deleted.

Guard transitions with assertions, not ifs. #[Assertions], #[Assertion\Property], and #[Assertion\Callback] (backed by src/Automated) let you encode "an order can only ship when paid" declaratively, including automated state switching when properties satisfy constraints. Reach for #[Assertion\Callback] when the guard needs app context like a clock or a service.

Declare the default state deliberately. StateDefault marks the state auto-enabled at instantiation. "Which state am I in when nothing set one" is the most common source of confusing test failures.

Persist state yourself in Laravel. The infrastructures/doctrine traits (AbstractStandardObject, StandardTrait) target Doctrine ORM/ODM entities. If your model is Eloquent, that integration does not apply and you write the glue: a state column storing the state class or a short key, hydration on model boot, save on transition. The package is an in-process behavior engine with no persistence opinion of its own.

Wrap it behind your own seam. Introduce a HasStates contract or small StateMachine adapter you own, and let domain classes reach state only through it. If the PHP 8.4 floor or the PHPStan-extension fragility ever forces a swap to symfony/workflow or enums, you rewrite one adapter instead of forty models.

Bind collaborators through the container, not app() inside closures. The closure is rebound to the proxy, which is convenient but easy to abuse; inject services into the stated class and let the closure read $this->pricing rather than resolving globally.

Parity-test the migration. Keep the old conditional path behind a flag and assert the new state path produces identical observable behavior. State classes unit-test in isolation without mocks; proxy-level tests are your integration tests.

Pin both the package and PHPStan to exact versions. Commit composer.lock for the application. Upstream's "lock is ignored" convention is a library-authoring rule and does not apply to consumers.

Use the demos as a post-bump smoke test. They exercise dispatch without requiring your app to boot.


Gotchas and Tips

A state method that forgets to return a Closure fails late — at call time, on the code path that touches that state. No compile-time check exists. Enforce : Closure return types in review and make sure tests exercise every state, or a broken state ships silently.

$this inside a state closure is the proxy, not the state. State-instance data doesn't live where you'd expect; anything the closure needs lives on the model. This is the first thing that trips up developers coming from "state objects hold data."

Child states override parents by short class name. Two states sharing a short name across namespaces collide. Name them distinctly.

Visibility is enforced via debug_backtrace(). It mirrors PHP's rules but behaves differently when the call path is mediated by framework proxies — Doctrine lazy proxies, DI decorators, mocking tooling. If a state method is unexpectedly not callable, inspect the stack. Also measure the per-call overhead if you invoke state methods in a hot loop.

Attribute scanning plus RuntimeCache implies warm-up cost and cache coherence questions. Under Octane/RoadRunner/Swoole the cache persists between requests; verify state changes in one request are visible in the next rather than assuming.

Serialization is an open question. __serialize() is reserved by the proxy, and state serialization semantics deserve a spike before stated models go into queued jobs or session storage. This is precisely the class of bug that passes tests and breaks in a real queue.

Convert statesListDeclaration() in the same PR. Laravel's error handler and a strict PHPUnit config both turn E_USER_DEPRECATED into a build failure; upstream's own suite fails on any deprecation, notice, or warning.

The bundled PHPStan extension tracks PHPStan internals outside its BC promise. Upstream ships patch releases specifically to repair breaks from PHPStan patch bumps, and their own phpstan.neon doesn't even load the extension. Pin PHPStan hard; if a security update breaks it, drop the extension from your config rather than block the update — accept weaker static analysis on state-dispatched calls.

Never run bare make if you fork or vendor the package. The default target is clean depend — it does rm -rf vendor and composer update. Use make qa-offline for local checks, and keep an internal patch branch so a fork stays buildable.

Concurrency isn't modeled. There is no locking or transactional semantics; if two requests can transition the same aggregate, you own the SELECT ... FOR UPDATE or optimistic-lock story in Laravel. State machines make valid transitions explicit — they do not make them atomic.

Bus factor 1 (10 stars, 0 dependents, no commercial support). Your mitigants are institutional: an internal owner, an internal one-page how-to covering the closure-builder rule and StateDefault, a pinned lockfile, and confirmed fork-readiness under BSD-3-Clause.

Where not to use it: anemic Eloquent models whose logic lives in service classes (this package addresses entities, not services), 2–4 state models where Enum + match is clearer and dependency-free, and anything that actually wants orchestration, retries, and a persisted transition log — this is the State pattern, not a workflow engine.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core