Scheduled transitions and the sweep
Schedule a transition for an instant; the sweep runs it as the system:
// The transition must be allowSystem() (or systemOnly() and scheduled with asSystem()).
$scheduled = Lifecycles::for($listing)->by($editor)->because('Launch')->schedule('publish', $publishAt);
Lifecycles::for($listing)->asSystem()->schedule('expire', $at); // a systemOnly() transition
Lifecycles::for($listing)->scheduled(); // list<ScheduledTransition>
Lifecycles::for($listing)->cancelScheduled('publish'); // bool
Lifecycles::for($listing)->retryScheduled('publish'); // bool: this listing's newest failed one, back to pendingScheduling checks the structure, freeze and seal, the actor rules, reason and payload now, against the stored state; time rules, quotas and rate limits are checked when it runs. A refusal throws TransitionDeniedException. A user scheduling a systemOnly() transition is refused with system_only, and a transition that marks a sensitive() key required cannot be scheduled (invalid_payload), because schedules never store sensitive keys. One pending schedule exists per transition (and one expiry), and scheduling again replaces it. Leaving the state it was created in cancels it.
The sweep
php artisan lifecycle:sweep (every minute) sends due warnings, then runs due schedules in batches, each in its own transaction, in system context:
- Refused for temporary reasons: retried after schedules.retry_after (or when the denial says), up to schedules.max_attempts, then failed. A permanent denial fails at once. Both fire ScheduledTransitionFailed.
- Frozen subject: deferred, without using up an attempt.
- Soft-deleted subject: its schedules are paused until it is restored (a restore without model events, such as restoreQuietly(), is picked up by the next sweep). A deleted subject cancels its schedules.
- An exception: it is reported to your exception handler and counted as an attempt. The rest of the sweep continues. A warning that throws is reported too and never stops the sweep; a warning whose model, lifecycle or state no longer exists stops warning.
Lifecycles::sweep(); // SweepResult: warned, executed, deferred, failed, errored, cancelled, skipped, queued
Lifecycles::sweep(limit: 100, queue: true); // dispatch RunScheduledTransitionJob per schedule
Lifecycles::schedules()->runDue(); // due schedules only
Lifecycles::schedules()->warn(); // warnings only
Lifecycles::schedules()->due(); // Collection<ScheduledTransition>, read-only
Lifecycles::schedules()->failed(); // Collection<ScheduledTransition>, most recently failed first
Lifecycles::schedules()->retry($scheduleId); // failed → pending, attempts reset
Lifecycles::sweep(connection: 'tenant'); // the package tables of another connectionSweepResult counts warned, executed, deferred, failed, errored, cancelled, skipped and queued, plus total(). “Now” is always the clock — there is no way to sweep “as of” a future instant; in tests, travel with Carbon::setTestNow().
Database connections
Package rows live on their subject’s connection, and one sweep covers one connection’s package tables: the default, or the one named with lifecycle:sweep --database=<connection>, Lifecycles::sweep(connection: …) or SweepOptions::$connection. Schedule one sweep per connection your subjects use — lifecycle:validate warns about a listed model whose connection has none. Schedule ids are per connection: pass it to schedules()->retry($id, $connection), while a handle’s retryScheduled() uses its subject’s connection.
Failed schedules
A ScheduledTransition says which subject it belongs to (subjectType, subjectId) and, once it has finished, how it ended (status, outcome, lastDenial, finishedAt). schedules()->failed() lists the failed ones; retry one by id with schedules()->retry(), or per subject with retryScheduled() — an expiry is retried by the name of its expiry transition (retryScheduled('expire')). To be alerted when a schedule gives up, listen for ScheduledTransitionFailed:
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use RoundlyConsulting\Lifecycle\Events\ScheduledTransitionFailed;
Event::listen(function (ScheduledTransitionFailed $event): void {
Log::warning('Scheduled transition failed', ['schedule' => $event->scheduleId, 'transition' => $event->transition]);
});Queued sweeps
With --queue, queue: true or schedules.queue.enabled, the sweep dispatches one RunScheduledTransitionJob per due schedule on schedules.queue.connection / schedules.queue.name. Each schedule gets one unique job, even when the queue stalls between sweeps, and the job re-checks everything under the lock — the retry and failure accounting is the same as inline.
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.