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

Applying transitions

Build a handle with the call’s context, then apply a transition by name — or by target state:

use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

// A handle per subject (and lifecycle), with the call's context
Lifecycles::for($listing)
    ->by($user)                         // the actor (default: the authenticated user)
    ->because('Back in stock')          // the reason, stored in history
    ->with(['note' => 'Restocked'])     // the payload, validated by the transition's rules()
    ->apply('reopen');

Lifecycles::for($listing)->transitionTo(ListingStatus::Closed);   // by target state
Lifecycles::for($listing)->asSystem()->apply('expire');            // system context

The trait shorthand

$listing->transition('close', ['note' => 'Sold elsewhere']);   // name, payload, lifecycle
$listing->canTransition('reopen');                             // bool
$listing->lifecycle()->by($user)->because('Back in stock')->apply('reopen');
$listing->transitionTo(ListingStatus::Archived);

Results and refusals

apply() returns a TransitionResult (subject, lifecycle, transition, from, to, record, replayed). A refused call throws TransitionDeniedException and leaves the model exactly as it was. attempt() returns the refusal instead of throwing:

$attempt = Lifecycles::for($listing)->by($user)->attempt('close');

if (! $attempt->succeeded) {
    return back()->withErrors($attempt->decision->messages());
}

transitionTo() picks the one transition from the current state to the target. When none exists, the call is denied with no_transition_to_state; when several exist, it throws AmbiguousTransitionException — call apply() with the name instead.

Payloads and reasons

A payload is validated by the transition’s rules(), and only the validated keys reach guards, handlers and history — minus sensitive() keys, which are never stored. Sending a payload to a transition that declares no rules() throws InvalidLifecycleUsageException instead of silently dropping it:

$lifecycle->transition('refund')
    ->from('captured')->to('refunded')
    ->requiresReason(5)
    ->rules(['amount' => 'required|integer|min:1', 'card_number' => 'nullable|string'])
    ->sensitive('card_number');

Lifecycles::for($payment)->because('Customer request')->with(['amount' => 500])->apply('refund');

Reasons are capped by history.reason_max_length and the stored context by history.max_context_bytes; with history.store_payload off, no payload is stored at all.

Optimistic versions

Each state change increases the record’s version by one. Send it with your form or API response and pass it back:

Lifecycles::for($listing)->version();                         // e.g. 4, sent to the client
Lifecycles::for($listing)->expectingVersion(4)->apply('close'); // refused with stale_version if it moved on

Idempotency keys

For webhooks and retries, an idempotency key applies a transition at most once:

$result = Lifecycles::for($order)->idempotencyKey("payments:{$event->id}")->apply('pay');
$result->replayed;   // true when this key was already applied; nothing ran again

A replay returns the original result without guards, writes or events. Reusing the key for another transition throws IdempotencyConflictException. A replay does not check actor rules, so use keys that cannot be guessed or that include the actor.

Dirty models

apply() saves the model after its handlers when anything is dirty, including changes you made before calling it. A dirty lifecycle attribute itself is a usage error, and so is a handler or hook that changes the state being written. Inside handlers, every other model (and every other lifecycle of the same model) keeps strict writes: change another subject’s state with a transition. If anything throws, everything rolls back, including the in-memory model — see Handlers, hooks, stamps and snapshots for the full transaction.

A soft-deleted subject throws SubjectTrashedException, and a transition into a quota’d state that would also change one of the quota’s scope columns throws InvalidLifecycleUsageException — see Limits, quotas and freezes.

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.