---
title: "Migrating from status enums — Lifecycle for Laravel | Roundly"
description: "Map hand-rolled status enums, checks and commands onto the package, and move an existing table over with lifecycle:adopt."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/migrating
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Lifecycle for Laravel](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)

# 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

```php
// 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.

```bash
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)
