Restrictions and guards
Every check runs through one pipeline in a fixed order. The first three steps are structural and stop evaluation; the rest are all collected, so a UI can show every reason at once:
- 1. The transition exists (unknown_transition, no_transition_to_state), the state is not terminal (terminal_state) and the transition leaves the current state (not_from_current_state).
- 2. The expected version (stale_version), freeze (frozen) and seal (sealed).
- 3. Context: system_only / system_not_allowed.
- 4. Actor rules: actor_required, actor_not_allowed, unauthorized (Gate).
- 5. Input: reason_required, reason_too_long, invalid_payload.
- 6. Time: not_yet_available, deadline_passed, min_dwell_not_reached, cooldown_active.
- 7. max_occurrences_reached, your guards (lifecycle-wide first), quota_exceeded.
- 8. Rate limits (rate_limited) — counted only when everything else passed.
A soft-deleted subject is refused before any of these: subject_trashed is structural too, and it is the only denial returned.
System context
asSystem(), the sweep and queued jobs run in system context, which skips actor rules, the reason requirement, minimum dwell, cooldowns and rate limits. It is code-level only and cannot be set through input. A transition must declare systemOnly() or allowSystem() to run in it. A transition with no actor rule can be run by any code path — add ability(), actors(), actor() or requiresActor() when the actor matters.
Declaring restrictions
$lifecycle->transition('approve')
->from('pending')->to('approved')
->ability('approve') // Gate / policy
->actors(User::class) // only users, not API clients
->requiresReason(10)
->rules(['note' => 'required|string|max:500'], ['note.required' => 'Say why.'])
->when(fn (TransitionContext $context): bool => $context->subject->photos()->exists(), code: 'no_photos')
->when(EnsureInvoicePaid::class) // a Guard class, resolved per call
->notAfter('deadline_at');Gate abilities receive the subject and the TransitionContext; a null actor is actor_required, never “guest allowed”:
Gate::define('publish', fn (User $user, Listing $listing, TransitionContext $context): bool => $listing->user_id === $user->id);Custom guards
A guard returns true or null to allow, false to deny with its code (default guard_failed), or its own Denial. Guard classes are resolved from the container per call:
use RoundlyConsulting\Lifecycle\Contracts\Guard;
use RoundlyConsulting\Lifecycle\DataTransferObjects\Denial;
use RoundlyConsulting\Lifecycle\DataTransferObjects\TransitionContext;
final readonly class EnsureInvoicePaid implements Guard
{
public function __construct(private Billing $billing) {}
public function check(TransitionContext $context): ?Denial
{
return $this->billing->isPaid($context->subject)
? null
: Denial::of('invoice_unpaid', message: 'Pay the invoice first.', retryAfter: now()->addHour()->toImmutable());
}
}Lifecycle-wide guards ($lifecycle->guard(...)) run before transition guards, and every guard runs. A custom code is retryable only when it carries retryAfter. Its message comes from the denial, else from lifecycle::denials.<code> when you add that key to lang/vendor/lifecycle, else the generic guard_failed text:
// lang/vendor/lifecycle/en/denials.php (after --tag="lifecycle-translations")
return [
// … package keys …
'closed_now' => '":transition" is only possible during opening hours.',
];TransitionContext gives guards, handlers and closures the subject, lifecycle, transition (definition), from, to, actor, system, reason, payload, now (UTC), version and scheduleId.
Denial codes
The DenialCode values, with English and Slovak messages in lifecycle::denials. Retryable denials carry a retryAfter, and the sweep retries scheduled transitions only for those:
| Code | Retryable | Meaning |
|---|---|---|
unknown_transition | — | The transition does not exist (structural). |
no_transition_to_state | — | transitionTo() found no transition to the target (structural). |
terminal_state | — | The current state is final (structural). |
not_from_current_state | — | The transition does not leave the current state (structural). |
subject_trashed | — | The subject is soft-deleted (structural) — the answer of check(), allowedTransitions() and canRollback(); apply() and rollback() throw SubjectTrashedException instead. |
stale_version | — | expectingVersion() no longer matches. |
frozen | — | The lifecycle is frozen. |
sealed | — | The state is sealed (sealedAfter()). |
system_only | — | Only system context may run it. |
system_not_allowed | — | System context may not run it. |
actor_required | — | An actor is required. |
actor_not_allowed | — | actors() or actor() refused the actor. |
unauthorized | — | The Gate ability refused. |
reason_required, reason_too_long | — | The reason is missing, or longer than history.reason_max_length. |
invalid_payload | — | Payload validation failed; field errors are in errors. |
not_yet_available | ✓ | notBefore() has not been reached. |
deadline_passed | — | notAfter() has passed. |
min_dwell_not_reached | ✓ | The minimum time in the state has not passed. |
cooldown_active | ✓ | The cooldown since the last run is still running. |
max_occurrences_reached | — | maxOccurrences() is used up. |
guard_failed | ✓ | A guard refused with the default code. |
quota_exceeded | ✓ | The target state’s quota is full. |
rate_limited | ✓ | A rateLimit() is exhausted. |
nothing_to_rollback, not_on_path, state_mismatch, irreversible, not_reversible, rollback_window_passed, rollback_conflict | — | Rollback refusals — see Rollbacks and history. |
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.