---
title: "Restrictions and guards — Lifecycle for Laravel | Roundly"
description: "The guard pipeline in order — system context, actor rules, Gate abilities, reasons, payloads, custom guards — and every denial code."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/restrictions
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Lifecycle for Laravel](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)

# 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

```php
$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”:

```php
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:

```php
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:

```php
// 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)
