NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Lifecycle for Laravel

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 haveReplace with
A transitions map and canTransitionTo() on the enumtransition('confirm')->from(Status::Pending)->to(Status::Confirmed)
isFinal() on the enumterminal(Status::Completed, Status::Cancelled)
An action that reads the status, checks the map and savesLifecycles::for($model)->apply('confirm')
A StatusChanged event dispatched inside the transactionLifecycleTransitioned (after commit)
A paid → paid_at timestamp mapstate(Status::Paid)->stamps('paid_at')
Side effects in the action (stock, credits)handledBy(SellStock::class) + CompensatesTransition
A Scheduled status plus a “publish scheduled” commandLifecycles::for($post)->schedule('publish', $at)
A status derived from expires_at on every readexpiresAtAttribute('expires_at')->expiresVia('expire')
An “expiring soon” command that re-notifies on every runttl() + warnBefore() + renew()
“Reopen at most 3 times” countersmaxOccurrences(3)
Approver or role lists checked in some code paths onlyability('approve') / actors(User::class)
Webhook-driven status changes that may arrive twiceidempotencyKey("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 now

In 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 crypto

By 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.