NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Lifecycle for Laravel

Limits, quotas and freezes

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

$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);
LimitDeclared withDenial codeSystem context
Max occurrencestransition()->maxOccurrences(3)max_occurrences_reachedapplies
Cooldowntransition()->cooldown('24 hours')cooldown_activeskipped
Minimum dwellstate()->minDwell('1 hour')min_dwell_not_reachedskipped
Sealstate()->sealedAfter('90 days')sealedapplies
FreezeLifecycles::for($m)->freeze()frozenapplies
DeadlinesnotBefore() / notAfter()not_yet_available / deadline_passedapplies
Rate limittransition()->rateLimit(10, '1 hour')rate_limitedskipped
Quotastate()->quota(5, scope: 'user_id')quota_exceededapplies

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

$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

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

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

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