---
title: "Defining a lifecycle — Lifecycle for Laravel | Roundly"
description: "Declare states, the initial and terminal states and named transitions in a definition class — every builder method and how to validate it."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/defining-lifecycles
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)

# Defining a lifecycle

Any Eloquent model with a status fits — a few typical lifecycles:

| Model | Typical lifecycle | Rules |
| --- | --- | --- |
| Order | `pending → paid → shipped, refunded` | payment status as a second lifecycle |
| Ticket | `new → open → waiting → resolved` | reopened at most 3 times |
| Listing | `draft → active → expired` | expires after 30 days, at most 5 active per customer |
| Subscription | `trial → active → grace → cancelled` | renewed or reactivated |
| Application | `submitted → in review → approved / rejected` | undo within an hour |

A definition extends LifecycleDefinition and declares everything in define(). States are the cases of a backed enum (string or int) or plain strings:

```php
enum ListingStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Closed = 'closed';
    case Expired = 'expired';
    case Archived = 'archived';
}
```

```php
use RoundlyConsulting\Lifecycle\Definition\LifecycleBuilder;
use RoundlyConsulting\Lifecycle\Definition\LifecycleDefinition;

final class ListingLifecycle extends LifecycleDefinition
{
    public function define(LifecycleBuilder $lifecycle): void
    {
        $lifecycle->states(ListingStatus::class)
            ->initial(ListingStatus::Draft)
            ->terminal(ListingStatus::Archived);

        $lifecycle->state(ListingStatus::Active)
            ->ttl('30 days')->grace('3 days')->warnBefore('7 days', '1 day')
            ->expiresVia('expire')
            ->quota(5, scope: 'user_id')
            ->stamps('published_at');

        $lifecycle->transition('publish')
            ->from(ListingStatus::Draft)->to(ListingStatus::Active)
            ->ability('publish')
            ->allowSystem();

        $lifecycle->transition('close')
            ->from(ListingStatus::Active)->to(ListingStatus::Closed)
            ->rules(['note' => 'nullable|string|max:500']);

        $lifecycle->transition('reopen')
            ->from(ListingStatus::Closed)->to(ListingStatus::Active)
            ->maxOccurrences(3)->cooldown('1 hour')->requiresReason()
            ->rules(['note' => 'nullable|string|max:500']);

        $lifecycle->transition('expire')
            ->from(ListingStatus::Active)->to(ListingStatus::Expired)
            ->systemOnly();

        $lifecycle->transition('reactivate')
            ->from(ListingStatus::Expired)->to(ListingStatus::Active);

        $lifecycle->transition('archive')
            ->from('*')->to(ListingStatus::Archived)
            ->irreversible();
    }
}
```

A definition is compiled once per process, validated — errors throw InvalidLifecycleDefinitionException with every issue listed — and then immutable. The class is built with new, never through the container: put dependencies into guards, handlers and hooks given as class names, which are resolved from the container on every run. define() must not read request state; dynamic values belong in closures.

## Generate a definition

make:lifecycle writes a definition class into App\\Lifecycles — a small valid example with string states, or the cases of a backed enum as states, starting in the first case:

```bash
php artisan make:lifecycle ListingLifecycle
php artisan make:lifecycle ListingLifecycle --enum="App\Enums\ListingStatus"

# Edit the stubs make:lifecycle uses
php artisan vendor:publish --tag=lifecycle-stubs
```

## LifecycleBuilder

| Method | Meaning |
| --- | --- |
| `states(ListingStatus::class) / states(['draft', 'active'])` | Every case of a backed enum (string or int), or a list of enum cases or strings — never mixed. |
| `initial($state)` | The state a new model starts in. Exactly one; it cannot be terminal. |
| `terminal(...$states)` | Final states — nothing leaves them. |
| `state($state)` | A StateBuilder for one state’s settings. |
| `transition('name')` | A TransitionBuilder. Names: letters, digits, \_ . : -, up to 64 characters. |
| `guard($guard)` | A guard every transition of this lifecycle runs, before the transition’s own guards. |
| `label('…'), meta([...])` | Display label and free-form metadata. |

## StateBuilder

| Method | Meaning |
| --- | --- |
| `label('…' \| fn () => …)` | Display label. Default: the enum’s label() when it has one, else the headline of the value, through the translator. |
| `ttl('30 days' \| fn (Model $m) => interval\|instant\|null)` | Time to expiry after entering the state. |
| `expiresAtAttribute('ends_at')` | The model’s own datetime column is the expiry instant (null = never). |
| `grace('3 days')` | Extra time between expiry and the expiry transition running. |
| `warnBefore('7 days', '1 day')` | LifecycleExpiring warnings (up to 16 leads). |
| `expiresVia('expire')` | The transition that runs on expiry. It must leave this state and be systemOnly() or allowSystem(). |
| `quota($max \| fn (Model $m) => int, scope: 'user_id' \| [...], name: null)` | At most $max models of this table in this state per scope. |
| `minDwell('1 hour')` | Minimum time in the state before user transitions may leave it. |
| `sealedAfter('14 days')` | After this time in the state, it can no longer be left. |
| `stamps('published_at', …)` | Columns set to “now” whenever the state is entered by a transition. |
| `onEnter($hook), onExit($hook)` | StateHook (class, instance or closure) run inside the transaction. |
| `meta([...])` | Free-form metadata. |

## TransitionBuilder

| Method | Meaning |
| --- | --- |
| `from(...$states)` | Source states; '\*' = every non-terminal state except the target. |
| `fromAnyExcept(...$states)` | The wildcard minus the listed states. |
| `to($state)` | The target state. |
| `allowSelf()` | Allow from to contain to (a self-transition keeps entered\_at and its schedules; an expiresAtAttribute expiry follows the attribute). |
| `systemOnly() / allowSystem()` | Only system code / users and system code. Without either, system code is refused. |
| `requiresActor(), actors(User::class, …), actor(fn (?Model $actor, Model $subject) => bool)` | Actor rules. |
| `ability('publish')` | Gate ability, checked as Gate::forUser($actor)->allows('publish', \[$subject, $context\]). |
| `requiresReason(int $minLength = 1)` | A reason is required (user context). |
| `rules([...], [...messages]), sensitive('card_number', …)` | Payload validation. Only validated keys are kept; sensitive keys are never stored. |
| `when($guard, code: null, message: null)` | A custom guard: class, instance or closure. |
| `notBefore('available_from' \| fn (Model $m) => ?instant, offset: null)` | Not allowed before an instant. A string is always an attribute name. |
| `notAfter('deadline' \| fn (Model $m) => ?instant, offset: null)` | Not allowed after an instant. The instant itself is still allowed. |
| `maxOccurrences(3)` | Per subject; counted from the state record, so pruning history never resets it. |
| `cooldown('1 hour')` | Minimum time between two runs of this transition (user context). |
| `rateLimit(10, '1 hour', RateLimitScope::Actor)` | Through Laravel’s RateLimiter; per Actor, Subject or ActorAndSubject (a call without an actor counts per subject). Several per transition. The key must fit the cache’s 250-character limit — give very long model class names a morph map alias. |
| `ignoresFreeze(), ignoresSeal(), ignoresMinDwell()` | Exemptions. |
| `handledBy($handler)` | A TransitionHandler (class, instance or closure) run inside the transaction. |
| `irreversible(), reversible(within: '1 hour', withoutCompensation: false)` | Rollback rules. |
| `rollbackRequires('ability'), rollbackGuard($guard)` | Who may roll this transition back. |
| `snapshots('price', …)` | Attributes captured before and after, restored on rollback. |
| `label('…'), meta([...])` | Display label and free-form metadata. |

## Durations and time

Durations are CarbonInterval, DateInterval or strings such as '30 days'. Months and years never overflow (31 January + 1 month = 28 February), and days are exact (30 days = 720 hours across a daylight-saving change). All package timestamps are stored in UTC.

## Validate in CI

Run lifecycle:validate --strict in CI. It lists every error and warning — unreachable states, dead ends, ambiguous targets, handlers that make a transition irreversible — and, for Model:attribute, checks that the stamp, expiry-attribute and quota-scope columns the definition names exist and that every rate-limited transition’s key fits the cache’s 250-character limit (rate\_limit\_key\_too\_long):

```bash
php artisan lifecycle:validate --strict
php artisan lifecycle:validate "App\Models\Listing:status"
```

Builder methods never throw: bad input (an invalid duration, a negative limit, a column name that is not an identifier) is recorded as an issue, and the validator reports them all at once. Lifecycles::definitions()->validate($class) returns the same ValidationReport without throwing.

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