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:
enum ListingStatus: string
{
case Draft = 'draft';
case Active = 'active';
case Closed = 'closed';
case Expired = 'expired';
case Archived = 'archived';
}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:
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-stubsLifecycleBuilder
| 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):
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 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.