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 contextThe 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 onIdempotency 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 againA 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 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.