Migrating from status enums
Most applications start with a status column, a backed enum and a few hand-written checks. The usual patterns map onto the package like this — row locks, compare-and-swap writes and restoring the in-memory model after a failure come built in:
| You have | Replace with |
|---|---|
| A transitions map and canTransitionTo() on the enum | transition('confirm')->from(Status::Pending)->to(Status::Confirmed) |
| isFinal() on the enum | terminal(Status::Completed, Status::Cancelled) |
| An action that reads the status, checks the map and saves | Lifecycles::for($model)->apply('confirm') |
| A StatusChanged event dispatched inside the transaction | LifecycleTransitioned (after commit) |
| A paid → paid_at timestamp map | state(Status::Paid)->stamps('paid_at') |
| Side effects in the action (stock, credits) | handledBy(SellStock::class) + CompensatesTransition |
| A Scheduled status plus a “publish scheduled” command | Lifecycles::for($post)->schedule('publish', $at) |
| A status derived from expires_at on every read | expiresAtAttribute('expires_at')->expiresVia('expire') |
| An “expiring soon” command that re-notifies on every run | ttl() + warnBefore() + renew() |
| “Reopen at most 3 times” counters | maxOccurrences(3) |
| Approver or role lists checked in some code paths only | ability('approve') / actors(User::class) |
| Webhook-driven status changes that may arrive twice | idempotencyKey("provider:{$eventId}") |
Before and after
// Before
public function execute(Appointment $appointment, Status $to): void
{
if (! $appointment->status->canTransitionTo($to)) {
throw new InvalidTransition;
}
$appointment->update(['status' => $to]);
event(new AppointmentStatusChanged($appointment, $to));
}
// After
$appointment->transitionTo(Status::Confirmed);
Lifecycles::for($appointment)->by($vet)->because('Patient did not come')->apply('mark_no_show');Moving an existing table
- 1. Install the package, set the key types, publish and run the migrations.
- 2. Write the definition with the states your column already holds — keep the enum and use it directly (states(Status::class)), then validate it against the table.
- 3. Make the model a subject (implements LifecycleSubject, use HasLifecycle, lifecycleDefinitions()). New rows get records and history automatically from now on.
- 4. Adopt the existing rows with lifecycle:adopt: each row gets a record, its stored state is adopted and NULL states become the initial state. Adopted stays start now; with TTL states, --no-expiry avoids scheduling expiries for every legacy row at once. The command is safe to re-run.
- 5. Replace the hand-written transition code with apply() / transitionTo(). With strict_writes on, a direct $model->status = …; save() now throws; query-builder updates are adopted on the next mutation.
- 6. Move side effects into handlers (inside the transaction) or LifecycleTransitioned listeners (after commit, for mail and external calls).
- 7. Schedule the sweep if you added TTLs or schedules.
- 8. Replace UI checks with can() / allowedTransitions() or LifecycleResource, and form validation with ValidTransition.
php artisan lifecycle:validate "App\Models\Order:status"
php artisan lifecycle:adopt "App\Models\Order" --no-expiry # or without --no-expiry to start TTLs nowIn seeders and factories, creating a model with any declared state is allowed and recorded. Changing a saved model’s state in afterCreating or a seeder needs Lifecycles::allowDirectWrites() (adopted immediately), or run the seeder with LIFECYCLE_STRICT_WRITES=false.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.