---
title: "Limits, quotas and freezes — Lifecycle for Laravel | Roundly"
description: "Max occurrences, cooldowns, minimum dwell, seals, rate limits, race-free quotas per scope, and freezing one lifecycle of a subject."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/limits-and-quotas
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)

# Limits, quotas and freezes

Limits live on the definition — on the state being entered or left, or on the transition:

```php
$lifecycle->state(ListingStatus::Active)
    ->quota(fn (Listing $listing): int => $listing->user->plan->max_active, scope: 'user_id')
    ->minDwell('1 hour')
    ->sealedAfter('90 days');

$lifecycle->transition('reopen')
    ->from(ListingStatus::Closed)->to(ListingStatus::Active)
    ->maxOccurrences(3)
    ->cooldown('24 hours')
    ->rateLimit(10, '1 hour', RateLimitScope::Actor);
```

| Limit | Declared with | Denial code | System context |
| --- | --- | --- | --- |
| Max occurrences | `transition()->maxOccurrences(3)` | `max_occurrences_reached` | applies |
| Cooldown | `transition()->cooldown('24 hours')` | `cooldown_active` | skipped |
| Minimum dwell | `state()->minDwell('1 hour')` | `min_dwell_not_reached` | skipped |
| Seal | `state()->sealedAfter('90 days')` | `sealed` | applies |
| Freeze | `Lifecycles::for($m)->freeze()` | `frozen` | applies |
| Deadlines | `notBefore() / notAfter()` | `not_yet_available / deadline_passed` | applies |
| Rate limit | `transition()->rateLimit(10, '1 hour')` | `rate_limited` | skipped |
| Quota | `state()->quota(5, scope: 'user_id')` | `quota_exceeded` | applies |

Max occurrences and cooldowns are counted from the state record’s counters, never from history — pruning history never resets a limit, and a rollback restores the counter the reverted row changed. Time limits are reached when now ≥ the limit, except notAfter(): the deadline itself is still allowed.

## Quotas

```php
$lifecycle->state(ListingStatus::Active)
    ->quota(5, scope: 'user_id')                                              // static
    ->quota(fn (Listing $l): int => $l->user->plan->max_active, scope: 'user_id', name: 'plan'); // per subject
```

Quotas count the subject’s own table: rows in the target state with the same scope values (a NULL scope value is its own group), excluding the subject itself and soft-deleted rows, and ignoring global scopes — so a console sweep counts the same as a tenant request. They are checked when a transition or rollback enters the state; creation, direct writes, restoring a soft-deleted subject and self-transitions are not counted, and a quota on the initial state is a definition error. A quota of 0 refuses everyone.

Concurrent entries are serialised through a lock row per scope value, so a burst of requests cannot exceed the limit — proven race-free under real concurrent load on PostgreSQL and MySQL. Changing a scope column of a model that is already in a quota’d state (moving a listing from user A to user B with a normal save()) is not re-checked.

A transition into a quota’d state that would also change one of its scope columns — an unsaved edit on the model, or a handler or hook — throws InvalidLifecycleUsageException::quotaScopeChanged and changes nothing, because the count and the lock were for the stored partition. Save the column on its own, before or after the transition. A rollback counts the quota in the partition it restores.

## Rate limits

```php
use RoundlyConsulting\Lifecycle\Enums\RateLimitScope;

$lifecycle->transition('reopen')
    ->rateLimit(10, '1 hour', RateLimitScope::Actor)
    ->rateLimit(100, '1 day', RateLimitScope::Subject);
```

Rate limits go through Laravel’s RateLimiter (your cache store). The key is built from rate\_limits.prefix, the subject’s morph type, the lifecycle, the transition, the actor and the subject key, each part escaped: a per-actor limit counts per model, lifecycle and transition, and a call without an actor counts per subject — never in one bucket shared by every actor-less caller. Hits are counted only when every other check passes and are never refunded, even when the transaction later fails. check() only looks; system context skips rate limits.

The key, together with the cache store’s prefix and the limiter’s :timer suffix, must fit 250 characters. A key that would not fit throws InvalidLifecycleUsageException::rateLimitKeyTooLong instead of failing inside the cache store, and lifecycle:validate reports it for listed models — give very long model class names a morph map alias.

## Freezing

```php
Lifecycles::for($listing)->by($moderator)->because('Under review')->freeze(until: now()->addDays(3));
Lifecycles::for($listing)->isFrozen();      // true
Lifecycles::for($listing)->frozenUntil();   // ?CarbonImmutable
Lifecycles::for($listing)->frozenReason();  // 'Under review'
Lifecycles::for($listing)->unfreeze();      // bool: whether it was frozen
```

A frozen lifecycle refuses transitions (frozen) unless a transition ignoresFreeze(), and rollbacks unless every reverted transition ignoresFreeze(). Due schedules wait for the freeze to end without using up an attempt — unless their transition ignoresFreeze(), which the sweep runs while frozen. A freeze reason longer than history.reason\_max\_length throws InvalidLifecycleUsageException. The freeze applies to one lifecycle of one subject. Freezing is not checked against definition rules and the actor is recorded for audit only — authorize freeze() in your own policies.

## Sealing

sealedAfter('90 days'): once a subject has been in the state that long, transitions out of it (unless ignoresSeal()) and rollbacks out of it are refused — in system context too. A TTL state with sealedAfter() needs an expiry transition that ignoresSeal().

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