Asking before acting
Ask whether a transition would be allowed — for buttons, menus and API hints:
Lifecycles::for($listing)->by($user)->can('close'); // bool
Lifecycles::for($listing)->by($user)->canTransitionTo(ListingStatus::Closed);
$decision = Lifecycles::for($listing)->by($user)->check('reopen');
$decision->allowed; // false
$decision->codes(); // ['max_occurrences_reached']
$decision->messages(); // ['"Reopen" can be performed at most 3 times.']
$decision->retryAfter; // a CarbonImmutable when every denial is temporary, else null
foreach (Lifecycles::for($listing)->by($user)->allowedTransitions(includeDenied: true) as $transition) {
$transition->name; // 'close'
$transition->allowed; // true/false
$transition->denials; // list<Denial> when refused
$transition->requiresReason; // show a reason field
$transition->payloadFields; // keys of its rules()
$transition->availableAt; // when a time-based denial lifts
}
Lifecycles::for($listing)->allowedStates(); // states reachable right nowDecision, Denial and AvailableTransition
- Decision — allowed, denials and retryAfter (a CarbonImmutable when every denial is temporary, else null), plus denied(), has($code), find($code), first(), codes(), messages(), isRetryable() and toArray().
- Denial — code, a translated message, params, retryAfter, errors (payload validation errors by key) and source. Codes are DenialCode enum values (frozen, quota_exceeded, rate_limited, …) or your guard’s own codes.
- AvailableTransition — name, label, to, toLabel, allowed, denials, requiresReason, payloadFields, availableAt (when a time-based denial lifts) and meta.
check() is advisory
check() runs the same pipeline as apply(), but apply() checks again under the row lock — someone else may act in between. A soft-deleted subject is refused subject_trashed by the checks; apply() and rollback() throw SubjectTrashedException for it. For a subject without a state record yet (or with drift), check() counts minimum dwell and seals from now, as the adoption in apply() does. Without a reason or payload, check(), can() and allowedTransitions() report “requires a reason” through requiresReason / payloadFields instead of as a denial, so a button for a transition that needs a reason is not shown as disabled.
Reading the handle
$handle = Lifecycles::for($listing);
$handle->state(); // the model's attribute as loaded (an enum case or a string)
$handle->effectiveState(); // the expiry target once expires_at has passed
$handle->is(ListingStatus::Active, ListingStatus::Closed);
$handle->isTerminal();
$handle->enteredAt(); // ?CarbonImmutable
$handle->version(); // 0 before the first record exists
$handle->definition(); // CompiledDefinition
$handle->isFrozen(); $handle->frozenUntil(); $handle->frozenReason();
$handle->scheduled(); // list<ScheduledTransition>
$handle->expiresAt(); $handle->isExpired(); $handle->isInGrace(); $handle->isExpiringWithin('3 days');
$handle->history(); $handle->lastTransition();state() is the model’s attribute as loaded — not re-read from the database (reload the model for that) and unchanged by an overdue expiry; effectiveState() is what the state will be once an overdue expiry runs, even before the sweep has run.
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.