spatie/laravel-model-states
Add state-machine behaviour to Eloquent models: each state is its own class, with serialization handled automatically and state transitions made easy.
Architecture fit
'state' => PaymentState::class), configuration lives on the abstract state class, and transitions are declared explicitly (allowTransition(Pending::class, Paid::class)). That keeps the blast radius local to one model + one abstraction class + N small state classes.default_transition in config), and the StateChanged event plus finalState support give us a hook for audit logging and side effects without forking the package.color() pattern). If our need is "enforce a lifecycle," this package is the better fit. If our need is "typed status constants," we should not adopt it — enums are enough and cheaper.Integration feasibility
composer require spatie/laravel-model-states, optionally publish a config file. No migrations or schema changes are required — the persisted column is a string containing the state's morph value. That makes the integration reversible and pilotable on a single model without a data migration project.Technical risk
dependents: 0 alongside 1,307 stars and a "last release 2026-11-04" (future-dated). Either the dependent count is unpopulated or it is not measuring Packagist installs. A TPM should not cite adoption numbers from this dataset in an ADR without independent verification (Packagist download stats, internal dependency graph).lockForUpdate() or a version column in the surrounding transaction — that is our responsibility, not the package's.StateChanged gives us a hook, but we own the persistence, retention, and PII decisions for transition history.Key questions
Stack fit
Migration path
Payment, Order, Subscription). The legacy status string column can usually be reused; no schema change is needed if we accept the string type.Compatibility
Sequencing
StateChanged into an audit table. Add an exception handler mapping invalid transitions to HTTP 409.Maintenance
Support
Scaling
StateChanged — audit listeners must be queued or batched; (3) write contention on the same rows, which the package does not solve for us.Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
| Unregistered/renamed state class | Exception on hydration for affected rows | Deploy new state class before any writer; keep old classes as deprecated aliases until data is clean |
| Illegal transition attempt | Uncaught exception surfaces as 500 | Central handler → 409 with current-state context in the response |
| Null state on new rows | Cast returns null, downstream NPEs | DB default + default() on the state class; add a data-quality check |
| Concurrent transitions | Lost update, duplicated side effects | lockForUpdate() in the transition transaction; idempotency keys on side effects |
| Bulk import/seed | Rows with values no state class maps to | Ban direct column writes in review; CI check that legacy status enums stay closed |
| Audit listener throws | Transition rolls back or partially applies | Queue audit writes; decide explicitly whether audit is inside or outside the transaction |
Ramp-up
How can I help you explore Laravel packages today?