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

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:

StatePhaseStampExpiry
draftDraft——
scheduledScheduledpublished_atcomplete at ends_at
postponedPostponedpostponed_atnever — a postponed event never auto-completes
rescheduledRescheduledrescheduled_atcomplete at ends_at
cancelledCancelled (terminal)cancelled_at—
completedCompleted (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 handleFromRules
publish()draftNot after ends_at.
unpublish()scheduledRefused while anyone is going or holding seats — postpone or cancel instead.
postpone(?$reason)scheduled, rescheduledKeeps the dates.
reschedule($start, ?$end, ?$reason)scheduled, postponed, rescheduledTimes 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, rescheduledHolds expire, promotion stops, dates kept.
complete()scheduled, rescheduledNot before starts_at; automatic at ends_at (lifecycle:sweep).
moveOnline($url, keepVenue: false)any but cancelled and completedSets 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)anyThe 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.

GuardDenial codeOn
Lifecycle\NoAttendeesno_attendeesunpublish: anyone going or holding seats.
Lifecycle\RescheduledThroughEventsrescheduled_through_eventsreschedule: 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, zinc

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