Give a state a TTL, an optional grace period and warning leads, and name the system transition that runs on expiry:
$lifecycle->state(ListingStatus::Active)
->ttl('30 days') // or ->ttl(fn (Listing $l) => $l->plan->duration) or ->expiresAtAttribute('ends_at')
->grace('3 days') // the expire transition runs at expires_at + grace
->warnBefore('7 days', '1 day')
->expiresVia('expire'); // a systemOnly() / allowSystem() transition leaving the stateEntering the state schedules its expiry. Leaving the state cancels it, and coming back schedules a fresh one. For one stay, the instant comes from expireAt() (if set), else the expiry attribute, else the TTL. The expiry transition runs at expires_at + grace, executed by lifecycle:sweep as a system transition.
A TTL closure receives the subject and returns an interval (from now), an instant or null (no expiry for this stay). With expiresAtAttribute(), changing the column on a normal model save re-syncs the pending expiry, unless an override (expireAt(), extend(), renew()) or neverExpire() is active for the stay. The re-sync uses the state the stored row is in, so a stale model that still believes it is in an expiring state schedules nothing; a soft-deleted subject’s expiry follows too and stays paused.
A self-transition keeps a TTL expiry (an interval or a closure). An attribute-based expiry follows its attribute instead, so a handler that moves the attribute during a self-transition is honoured, and an expiry cleared with neverExpire() stays cleared.
Reading and changing expiry
$handle = Lifecycles::for($listing);
$handle->expiresAt(); // CarbonImmutable (UTC) or null
$handle->isExpired(); // expires_at has passed (the sweep may not have run yet)
$handle->isInGrace(); // expired, but the expire transition is not due yet
$handle->isExpiringWithin('3 days');
$handle->effectiveState(); // ListingStatus::Expired the moment expires_at passes
$handle->state(); // the model's attribute as loaded, even when the expiry is overdue
$handle->extend('7 days'); // expires_at + 7 days
$handle->renew(); // now + the state's TTL (or ->renew('14 days'))
$handle->expireAt(now()->addWeek()); // an absolute instant for this stay
$handle->neverExpire(); // no expiry for this stayEach change replaces the pending expiry, so warnings start over. neverExpire() holds for the whole stay: neither a later change of the expiry attribute nor a self-transition brings the expiry back while the subject stays in the state. extend() needs a pending expiry, renew() needs an interval or a TTL, and expireAt() needs a state that can expire — otherwise they throw ExpiryException. Expiry changes are not checked against definition rules; the actor, when given, is recorded only, so authorize these calls in your own policies.
Warnings
warnBefore() fires LifecycleExpiring once per lead (subject, lifecycle, state, expiresAt, lead, scheduleId), guarded by a compare-and-swap so two concurrent sweeps fire it once. Leads that passed together fire once, with the nearest one, and no warning fires after expiry. A warning that throws is reported to your exception handler and never stops the sweep; a warning whose model, lifecycle or state no longer exists stops warning. The package sends no notifications itself: listen for LifecycleExpiring and notify your way.
Undoing an expiry
Expiry rows cannot be rolled back. Undo an expiry with a forward transition such as reactivate — it enters the state again with a fresh TTL.
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.