innoge/laravel-enum-states
A state machine for Eloquent built on native PHP enums. Define transitions, defaults, and lifecycle actions on the enum itself, register fields with a single attribute, and keep your normal model casts. Invalid transitions throw; defaults apply automatically.
Architecture fit. The package's core design claim — states are native PHP enum cases, the transition graph lives on the enum, and fields are registered via a single PHP attribute — is architecturally attractive for a TPM because it reduces the number of new concepts a team must absorb. Compare against the incumbent pattern (state classes + model trait + casts, e.g. spatie/laravel-model-states): that approach introduces a class-per-state, a trait on every model, and custom cast classes, all of which are places where behavior can drift. Here, the enum is domain vocabulary the team already owns, the model keeps its ordinary enum cast, and there is no persistence layer added. The trade-off is equally clear: by refusing state classes, the package also refuses a natural home for per-state behavior (labels, colors, allowed actor roles, side-effect handlers), so those tend to leak into Blade/API/Filament layers or match statements scattered across the codebase. Whether that is a win depends on how much state-conditional behavior exists in the domain — for a status field with 5–8 linear states it is a clear win; for an order/claim lifecycle with per-state business rules, it is a refactor waiting to happen.
Integration feasibility. High on paper. A typical adoption is: composer require, change a string column cast to the enum, add the attribute to the model, implement the transition method(s) on the enum. That is an afternoon, not a sprint, and it requires no database migration because the underlying column (string or int) is unchanged. The unknowns are all about enforcement: the "no model trait" positioning implies the package hooks Eloquent through some other mechanism (attribute discovery plus a booted callback, a global scope, or an explicit transition call). A TPM needs to know which, because it determines whether the guard is a hard boundary or an opt-in helper.
Technical risk. Dominated by vendor risk, not technology risk:
| Risk | Severity | Note |
|---|---|---|
| Bus factor / longevity | High | 5 stars, 0 dependents, single agency maintainer (InnoGE), no ecosystem signal |
| Laravel major-version lag | Medium | Small packages often trail new Laravel releases by months |
| Silent bypass of transitions | Medium–High | If guards only apply on a dedicated transition call, direct $model->status = X; $model->save() routes around them |
| No transition history | Medium | "No extra tables" is a feature and a ceiling — audit/event sourcing needs an append-only log |
| Concurrency | Medium | No mention of optimistic locking; read-modify-write on a status column is a classic lost-update path |
| Pre-1.0 / API churn | Unknown | Verify the version constraint in composer.json before committing |
Key questions to put to the maintainer (or answer in a 1-day spike):
saving/updating Eloquent events, or only when an explicit transition method is invoked? Can mass assignment or update() bypass it?UnitEnum casts, and does it interact cleanly with where('status', Status::Active) query building and scopes?UPDATE ... WHERE status = ?) or is that left to the caller?Stack fit. Requires PHP 8.1+ (native enums, attributes) and a modern Laravel/Eloquent version — verify exact constraints before planning. It is a pure-library, zero-infrastructure package: no migrations, no config file to publish (or a minimal one), no queue worker or cache dependency, no service provider side effects beyond package discovery. That makes it trivially compatible with containerized CI, Laravel Octane, and read-replica setups, and it means rollback is composer remove plus reverting a cast — an unusually clean escape hatch for a third-party dependency.
Migration path. Three distinct starting points, with very different costs:
'PENDING', 'pending', or NULL, run a one-off normalization query and add a NOT NULL default before flipping the cast. Hydration of an unrecognized value throws a ValueError, so a stale row becomes a 500 — this is the single highest-probability migration incident and should be scripted and rehearsed.enum casts with ad-hoc if guards: Low–medium. The work is consolidating scattered transition checks into the enum graph; behavior is preserved but you are deleting code, which needs test coverage to prove equivalence.spatie/laravel-model-states or a bespoke state machine: Medium–high. State classes carry behavior, custom casts, and often a history table. This package will not absorb any of that, so expect to relocate per-state logic into the enum (as methods) or into domain services, and to keep or replace the history table separately. Do not treat this as a drop-in swap.Compatibility. Confirm interaction with Filament/Nova admin panels (they often write fields directly, which is exactly the bypass risk above), API resources ($model->status->value vs. serialized enum), factory states, and queue payload serialization for models carrying enum casts. If the app exposes a public or partner API, the enum's backing values become a contract — treat renames as breaking changes and pin the package with a caret constraint plus a lockfile committed to the repo.
Sequencing. Recommended order, roughly 2–3 weeks of a single engineer's part-time effort:
CanTransition / a small TransitionService) that the rest of the app calls, so the package is behind a seam. This is the highest-leverage step for a TPM: it converts a vendor-risk conversation into a one-file swap if the package is abandoned.if checks and add a CI lint or static-analysis rule that flags direct writes to state columns outside the boundary.Maintenance. The package surface is small — an attribute, an enum contract, and a transition method — which is good: small surface means few upgrade surprises and a realistic hand-patch if the maintainer goes quiet. MIT license means forking is legally unencumbered. The real maintenance task is not the library, it is the discipline around it: the graph on the enum must be reviewed like a schema change. Add an owner and a code-review rule that any change to a transition method requires a test for both the allowed and the rejected path. Budget for a Laravel major-upgrade check once a year, and put the package on the same watch list as your other small dependencies.
Support. Community-only: GitHub issues, no SLA, no commercial backing disclosed. 0 dependents means the issue tracker is quiet — which reads as "stable" but equally as "unexercised." Mitigation is the adapter seam above plus committing the lockfile. Escalate to fork-and-maintain if a Laravel upgrade ever blocks you; the code volume is small enough that a mid-level engineer can own a fork.
Scaling. Runtime cost is negligible — enum casts are cheap, and the only open question is whether attribute reflection happens per transition or once at boot; either is fine at typical request volumes, and only worth measuring if you are doing thousands of transitions per second. The scaling concern is write concurrency, not throughput. Two workers transitioning the same record from Pending to Paid and Cancelled simultaneously will both read Pending, both pass the guard, and the last write wins — silently, with no error. If your domain has concurrent actors (payment webhooks plus admin actions, for example), you need a lockForUpdate() in a transaction or a conditional UPDATE ... WHERE status = <expected> around every transition. Do not assume the package provides this; ask, then verify with a two-process test.
Failure modes, ranked by blast radius:
Model::update() writes the column directly, skipping the graph. The state machine becomes documentation rather than enforcement.NULL value in the column throws on model load, turning one bad row into a 500 on any page that touches it.state_transitions table written by your adapter layer. The package's "no extra tables" promise is a starting-point convenience, not an auditability answer.Ramp-up. Low for developers: native enums and attributes are standard PHP 8.1+ knowledge, and the mental model ("the enum knows its own legal next states") is easier to teach than state classes. Expect under a day for a mid-level Laravel developer to be productive, and near-zero for reviewers who only need to read a transition method. The costlier ramp-up is organizational, not technical: teaching the team that the transition method is the only sanctioned write path, and enforcing that with review and static analysis until it is habit. Budget for that in the rollout plan — it is the difference between a state machine and a state machine that works.
TPM bottom line. Worth a one-day spike on a low-risk model. Adopt behind an application-level seam with a committed lockfile, and treat the concurrency and bypass questions as gate conditions rather than follow-ups. Do not adopt as a replacement for an existing state-class implementation with material per-state behavior, and do not rely on it for audit history.
How can I help you explore Laravel packages today?