Statuses and lifecycle
The status is owned by Lifecycle for Laravel and never written directly. The shipped Lifecycle\EventLifecycle, with its states in Enums\EventStatus:
| State | Phase | Stamp | Expiry |
|---|---|---|---|
draft | Draft | — | — |
scheduled | Scheduled | published_at | complete at ends_at |
postponed | Postponed | postponed_at | never — a postponed event never auto-completes |
rescheduled | Rescheduled | rescheduled_at | complete at ends_at |
cancelled | Cancelled (terminal) | cancelled_at | — |
completed | Completed (terminal) | completed_at | — |
The verbs
$handle = Events::for($event)->by($organizer);
// Each verb on its own — the table below says from which state.
$handle->publish(); // draft → scheduled
$handle->unpublish(); // back to draft while nobody is going or holding seats
$handle->postpone('The band is ill'); // dates kept, the new date still open
$handle->reschedule('2026-12-05 20:00', reason: 'New date'); // the only path to rescheduled
$handle->cancel('Venue flooded'); // holds expire, promotion stops, dates kept
$handle->complete(); // not before the start; automatic at ends_at
$handle->moveOnline('https://meet.example.com/jazz', keepVenue: true); // attendance becomes mixed
$handle->transition('approve', reason: 'Looks good'); // the generic path for your own lifecycle's steps| Verb on the handle | From | Rules |
|---|---|---|
publish() | draft | Not after ends_at. |
unpublish() | scheduled | Refused while anyone is going or holding seats — postpone or cancel instead. |
postpone(?$reason) | scheduled, rescheduled | Keeps the dates. |
reschedule($start, ?$end, ?$reason) | scheduled, postponed, rescheduled | Times written in the same transaction; previous_starts_at keeps the originally announced start; the doors time is cleared; holds ending after an earlier new start end at it; seats freed meanwhile go to the waitlist. On a draft it is a plain edit. |
cancel(?$reason) | scheduled, postponed, rescheduled | Holds expire, promotion stops, dates kept. |
complete() | scheduled, rescheduled | Not before starts_at; automatic at ends_at (lifecycle:sweep). |
moveOnline($url, keepVenue: false) | any but cancelled and completed | Sets the join link; drops the venue (online) or keeps it (mixed); moved_online_at, SEQUENCE raised, EventMovedOnline. On a draft it is an ordinary edit. |
transition($name, ?$reason, $payload) | any | The generic path for a host lifecycle’s own transitions. |
Every transition allows the system — a call without by() is trusted code; with by($actor) the event policy decides first. Every status change raises the calendar SEQUENCE and fires EventStatusChanged plus the phase event (EventPublished, EventPostponed, …). A denied transition throws EventTransitionDeniedException: denials() lists lifecycle’s codes, and a rate limit answers 429 with retryAfterSeconds().
A reschedule moves the date, always
The shipped reschedule transition carries the Lifecycle\RescheduledThroughEvents guard (denial code rescheduled_through_events): it passes only for Events::for($event)->reschedule(), which writes the new times in the same transaction. Every other path — Lifecycles::for($event, 'status')->asSystem()->apply('reschedule'), $event->transition('reschedule'), a lifecycle schedule of reschedule or the generic Events::for($event)->transition('reschedule') — is denied, because it would mark the event rescheduled with its old date and announce it.
| Guard | Denial code | On |
|---|---|---|
Lifecycle\NoAttendees | no_attendees | unpublish: anyone going or holding seats. |
Lifecycle\RescheduledThroughEvents | rescheduled_through_events | reschedule: anything but Events::for($event)->reschedule(). |
Scheduled and Rescheduled expire at ends_at through complete — lifecycle’s sweep runs it, and changing ends_at re-arms it. A postponed event never completes by itself. The transition names are constants on the definition: EventLifecycle::PUBLISH, UNPUBLISH, POSTPONE, RESCHEDULE, CANCEL and COMPLETE.
Phases
use RoundlyConsulting\Events\Enums\EventPhase;
$event->phase; // EventPhase of the current state
$event->phase->isActive(); // Scheduled or Rescheduled: RSVPs and check-in run
$event->phase->isTerminal(); // Cancelled or Completed
$event->phase === EventPhase::Postponed;
$event->status->color(); // the shipped EventStatus: gray, green, amber, blue, red, zincEvery state maps to an EventPhase, and every package rule — RSVPs, check-in, feeds, JSON-LD, notices — reads phases, never state names. That is what makes the workflow swappable.
Your own lifecycle
Point events.lifecycle at a LifecycleDefinition implementing Contracts\EventLifecycle — or extend the shipped Lifecycle\EventLifecycle, which is not final, and adjust define():
use RoundlyConsulting\Events\Contracts\EventLifecycle;
use RoundlyConsulting\Events\Enums\EventVerb;
use RoundlyConsulting\Lifecycle\Definition\LifecycleBuilder;
use RoundlyConsulting\Lifecycle\Definition\LifecycleDefinition;
final class ApprovalLifecycle extends LifecycleDefinition implements EventLifecycle
{
public function define(LifecycleBuilder $lifecycle): void
{
$lifecycle->states(MyStatus::class)->initial(MyStatus::Draft); // MyStatus implements Contracts\EventState (phase())
// … an approve step between draft and scheduled, the rest as you need
}
public function transitionFor(EventVerb $verb): ?string
{
return match ($verb) { EventVerb::Publish => 'submit', EventVerb::Cancel => 'cancel', default => null };
}
}
// config/events.php
'lifecycle' => App\Lifecycles\ApprovalLifecycle::class,- Every state maps to an EventPhase through Contracts\EventState, and the initial state must be in the Draft phase.
- A verb your lifecycle does not offer — transitionFor() returns null — throws UnknownEventTransitionException, naming transition() as the generic path.
- A definition that breaks the contract throws InvalidEventLifecycleException, and events:doctor reports it.
- A step of yours that must only happen together with a date change can carry the same guard: ->when(RescheduledThroughEvents::class).
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.