The Events facade
Everything goes through one facade, RoundlyConsulting\Events\Facades\Events — sugar over the EventsManager singleton, with no global alias. A venue, a category, an event with two ticket types, an RSVP, a door scan and a near-me search:
use RoundlyConsulting\Events\Facades\Events;
use RoundlyConsulting\Money\Money;
// A reusable venue: its timezone is how every wall-clock time below is read
$venue = Events::venues()->make('Blue Note')
->address(street: 'Hlavná 1', city: 'Bratislava', postalCode: '811 01', country: 'SK')
->coordinates(48.1439, 17.1097)
->timezone('Europe/Bratislava')
->create();
Events::categories()->make(['en' => 'Jazz', 'sk' => 'Jazz'])->create(); // categories are curated
$event = Events::make(['en' => 'Jazz Night', 'sk' => 'Jazzový večer'])
->description(['en' => 'A live quartet, then an open jam session.'])
->organizedBy($user)
->at($venue)
->startsAt('2026-11-14 20:00') // 20:00 in Bratislava, stored as UTC
->duration('3 hours') // or 'PT3H', a CarbonInterval, or ->endsAt(...)
->capacity(120)
->category('jazz')
->labels(['Live', 'Late night'])
->poster($request->file('poster'))
->tier('Free entry', key: 'free')
->tier('Supporter', price: Money::ofMajor('15', 'EUR'), capacity: 30) // key "supporter"
->publish();
// RSVP: a seat while there is one, the waitlist once it is full
$rsvp = Events::for($event)->rsvp($attendee)->going(tier: 'free');
$rsvp->isGoing(); // or isWaitlisted(), isHeld()
// At the door: scan the attendee's QR code, as the signed-in staff member
$result = Events::for($event)->by($doorStaff)->checkIn($scannedCode); // organizers and co-hosts only
$result->firstCheckIn; // false if the code was already used
// Discovery: upcoming published events (cancelled ones flagged by phase) within 25 km, nearest first
Events::browse()->upcoming()->near($here, km: 25)->orderByDistance($here)->paginate();Manager methods
| Method | Returns | Does |
|---|---|---|
make(array|string $title) | EventBuilder | Fluent builder; a locale map or a string in the current locale. |
create(CreateEventData $data) | Event | A draft — the builder’s code path. |
find(string $uuid) | ?Event | Live events only; a malformed uuid is null without a query; no visibility rule. |
findOrFail(string $uuid) | Event | EventNotFoundException otherwise. |
findBySlug(string $slug, ?Model $viewer = null) | ?Event | Along the locale chain; null for unlisted, private and draft events $viewer may not see without the link. |
for(Event|string $event) | EventHandle | By model or uuid; an unknown or trashed uuid throws EventNotFoundException. |
browse(?Model $viewer = null) | Builder<Event> | Visible to $viewer, listed (public) and announced; eager-loads venue, organizer, tiers, media and tags. |
visibleTo(?Model $viewer) | Builder<Event> | The policy’s view as a query — drafts and private events of managed events included. |
venues() | Accessors\Venues | Venues — below. |
categories() / labels() | Tags\Handles\TypeTags | tags-for-laravel’s handle for events:category / events:label, unchanged. |
rsvps() | Accessors\Rsvps | Payments, holds, codes and QR — below. |
invitations() | Accessors\Invitations | Answering from the invitation link — below. |
checkIn(string $code, Model $by, ?int $guests = null, bool $ignoreWindow = false) | CheckInResult | The unscoped door scan — the code finds its event. |
ics(iterable $events, ?Model $viewer = null) | IcsCalendar | A calendar file; skips events $viewer may not see. |
feeds() | Calendar\Feeds | public(EventFeedQuery) and for($attendee, includePrivate:). |
jsonLd(Event $event) | ?array | schema.org data, or null. |
sweep(?SweepOptions $options = null) | SweepResult | What events:sweep runs. |
repair(?RepairOptions $options = null) | RepairReport | What events:repair runs. |
Events::fake() | EventsFake | Facade only — the recording, still-performing fake (see Testing). |
forgetAttendee(), container() and perform() are public for the package’s own wiring and marked @internal — don’t call them from host code.
The for($event) handle
Events::for($event) returns an immutable EventHandle. by($actor) checks the event policy before every following call, asSystem() runs as trusted code and silently() writes without notices — each returns a new handle. Without by(), a call is trusted PHP (a job, a seeder, a command) and the policy is not asked. A mutator returns the handle so calls chain; a creator or a query returns its result. The bound event is updated in place, so event() returns it current:
$handle = Events::for($event)->by($organizer); // checks the policy before every call
$handle->tiers()->add('Crew', hidden: true, key: 'crew');
$handle->hosts()->add($partnerTeam);
$handle->publish()->event(); // a mutator returns the handle; event() is the bound, current model
Events::for($event)->asSystem()->postpone('Storm warning'); // trusted code: no actor, no policy
Events::for($event)->silently()->reschedule('2026-12-05 20:00'); // domain events still fire, no notices| Method | Returns | Ability (with by()) |
|---|---|---|
by(?Model $actor) | EventHandle | — |
asSystem() | EventHandle | — (no actor, no policy) |
silently() | EventHandle | — (domain events still fire, no notices) |
event() | Event | — |
update(UpdateEventData $data) | static | update |
edit() | EventEditor | update, on save() |
poster(UploadedFile|string|null $file = null, ?string $draft = null) | static | update |
categorize(Tag|int|string|null $category) | static | update |
labels() | EventLabels | — |
tiers() | EventTiers | — |
rsvp(Model $attendee) | PendingRsvp | — |
attendees() | EventAttendees | — |
waitlist() | EventWaitlist | — |
checkIn(string $code, ?int $guests = null, bool $ignoreWindow = false) | CheckInResult | checkIn |
hosts() | EventHosts | — |
invitations() | EventInvitations | — |
transferTo(Model&Organizer $organizer, bool $keepAsHost = false) | static | transfer |
counts() | EventCounts | viewCounts |
publish() · unpublish() · complete() | static | publish · unpublish · complete |
postpone(?string $reason = null) · cancel(?string $reason = null) | static | postpone · cancel |
reschedule(CarbonInterface|string $startsAt, CarbonInterface|string|null $endsAt = null, ?string $reason = null) | static | reschedule |
moveOnline(string $url, bool $keepVenue = false) | static | moveOnline |
transition(string $name, ?string $reason = null, array $payload = []) | TransitionResult | transition |
can(string $transition) · allowedTransitions() | bool · list<string> | false / [] when the actor lacks transition |
phase() · status() | EventPhase · EventState | — |
delete(bool $force = false) · restore() | static | delete · restore |
ics(?Model $viewer = null) | IcsCalendar | view |
jsonLd() | ?array | — |
$handle = Events::for($event);
$handle->phase(); // EventPhase — what every package rule reads
$handle->status(); // the lifecycle state (EventStatus with the shipped lifecycle)
$handle->can('cancel'); // false when the actor lacks the transition ability
$handle->allowedTransitions(); // list<string> of transition names allowed now
$handle->counts(); // EventCounts {going, interested, waitlisted, seatsLeft}
$handle->ics($viewer); // IcsCalendar
$handle->jsonLd(); // schema.org data, or nullmanager(), actor(), isSystem(), isSilent(), authorize() and context() are public for the sub-handles and marked @internal.
Ticket types: ->tiers()
A ticket type of another event is never found (TierNotFoundException):
| Method | Returns | Ability |
|---|---|---|
make(array|string $name) | TierBuilder | manageTiers |
create(TierData $data) | AdmissionTier | manageTiers |
add(array|string $name, ?Money $price = null, ?int $capacity = null, bool $hidden = false, ?string $key = null) | AdmissionTier | manageTiers |
update(AdmissionTier|int|string $tier, UpdateTierData $data) | AdmissionTier | manageTiers |
remove($tier) · restore($tier) | EventTiers · AdmissionTier | manageTiers |
reorder(array $order) | EventTiers | manageTiers |
all() | Collection<AdmissionTier> | — (hidden ones only for manageTiers) |
available() | Collection<AdmissionTier> | — (no policy) |
Answering: ->rsvp($attendee)
The attendee answering for themselves needs rsvp; anyone answering for someone else needs manageAttendees:
| Method | Returns | Notes |
|---|---|---|
by(?Model $actor) | PendingRsvp | Defaults to the handle’s actor. |
going(AdmissionTier|int|string|null $tier = null, int $guests = 0, ?string $note = null) | Rsvp | Going, held or waitlisted. |
interested() · notGoing() | Rsvp | — |
The guest list: ->attendees()
Every method takes the attendee model or its Rsvp; an RSVP of another event is refused:
| Method | Returns | Ability |
|---|---|---|
add(Model $attendee, $tier = null, int $guests = 0, bool $overCapacity = false) | Rsvp | manageAttendees |
remove(Rsvp|Model $who) | EventAttendees | manageAttendees |
changeTier(Rsvp|Model $who, AdmissionTier|int|string $tier) | Rsvp | manageAttendees |
changeGuests(Rsvp|Model $who, int $guests) | Rsvp | manageAttendees |
confirm(Rsvp|Model $who, ?string $reference = null) · release(Rsvp|Model $who) | Rsvp | manageAttendees |
checkIn(Rsvp|Model $who, ?int $guests = null) · undoCheckIn(Rsvp|Model $who) | CheckInResult · EventAttendees | checkIn |
find(Model $attendee) | ?Rsvp | the actor’s own, else manageAttendees |
going() · interested() · checkedIn() | Collection<Rsvp> | viewAttendees · viewInterested · checkIn |
Waitlist, co-hosts, invitations and labels
| Method | Returns | Ability |
|---|---|---|
waitlist()->all() · position(Model $attendee) · promote(?int $limit = null) | Collection<Rsvp> · ?int · Collection<Rsvp> | viewWaitlist · viewWaitlist · manageAttendees |
hosts()->add(Model&Organizer $host) · remove(Model $host) | EventHosts | manageHosts |
hosts()->all() · has(Model $host) | Collection<EventHost> · bool | view |
invitations()->send(Model $invitee, ?string $message = null) | EventInvitation | invite |
invitations()->revoke(Model $invitee) · all() · pending() | EventInvitations · Collection<EventInvitation> | invite |
labels()->attach($labels) · detach($labels) · sync(iterable $labels) | EventLabels | update |
labels()->all() | Collection<Tag> | — |
Events::venues()
Immutable: by() returns a new accessor whose writes check the VenuePolicy:
| Method | Returns | Policy (with an actor) |
|---|---|---|
by(?Model $actor) | Venues | Attributes create, update, delete, restore, geocode and the builder to $actor. |
make(array|string $name) | VenueBuilder | Carries the accessor’s actor. |
create(VenueData $data) | Venue | create (+ manageShared without an owner, or the owner must allow the actor); a DTO naming another actor than by() is refused. |
update(Venue $venue, UpdateVenueData $data) | Venue | update |
delete(Venue $venue) · restore(Venue $venue) | void · Venue | delete — refused (venue_in_use) while an unfinished event uses it · restore |
geocode(Venue $venue, bool $force = false) | Venue | geocode; only missing coordinates unless $force. |
find(int $id) · ownedBy(Model $owner) · near(Coordinates $point, float $km) | ?Venue · Builder<Venue> | Queries, no policy. |
Events::rsvps()
| Method | Returns | Notes |
|---|---|---|
by(?Model $actor) | Rsvps | confirm, release and rotateCode then check manageAttendees; requestPayment lets the RSVP’s own attendee through too. |
confirmPayment(string $reference) | Rsvp | Your payment webhook: held → going, idempotent; no policy. |
confirm(Rsvp $rsvp, ?string $reference = null) | Rsvp | A held RSVP, by hand. |
release(Rsvp $rsvp) | Rsvp | Give a hold’s seats back now. |
rotateCode(Rsvp $rsvp) | Rsvp | A new check-in code; the old one stops working. |
qr(Rsvp $rsvp, ?QrOptions $options = null) | ?Qr\ValueObjects\Svg | The code as an inline SVG; null without a code. |
findByCode(string $code) | ?Rsvp | The RSVP behind a code, or scanner input containing one. |
requestPayment(Rsvp $rsvp) | ?PaymentRequest | Ask the collector (again) for a held RSVP’s checkout. |
Events::invitations()
| Method | Returns | Notes |
|---|---|---|
asSystem() | Invitations | Trusted code: answers for any invitee. |
accept(string $token, RsvpResponse $as = RsvpResponse::Going, ?Model $by = null) | Rsvp | RSVPs the invitee (going or interested). |
decline(string $token, ?Model $by = null) | EventInvitation | — |
find(string $token) | ?EventInvitation | The live invitation, or null. |
Pass the request’s user as $by: an account invitee answers only signed in as themselves.
The event builder
Events::make($title) returns an EventBuilder. Setters store what you give and resolve the schedule on create(), once the timezone is known, so their order never changes the instant; they validate eagerly where they can:
| Setter | Notes |
|---|---|
description(array|string $description) | — |
slug(array $slug) | Manual per-locale slugs; a taken one throws invalid_event (never suffixed). |
organizedBy(Model&Organizer $organizer) | Required. |
coHostedBy(Model&Organizer ...$hosts) | Adds to the list. |
at(Venue|int|null $venue) | A model or its id. |
online(?string $url) | http(s), ≤ 2048 characters; stored encrypted. |
attendanceMode(AttendanceMode $mode) | Must agree with the venue and the link. |
timezone(string $timezone) | An IANA name. |
startsAt() · endsAt() · duration() | startsAt clears an all-day range, endsAt a duration, duration an end; duration takes 'PT3H', '3 hours' or a CarbonInterval. |
allDay(string $from, ?string $through = null) | Y-m-d local days, $through inclusive. |
doorsOpenAt(CarbonInterface|string $doorsOpenAt) | Within the 12 hours before the start. |
visibility(Visibility $visibility) · unlisted() · private() | — |
capacity(?int $capacity) · maxGuests(int $guests) | null capacity = unlimited. |
waitlist(bool $on = true, bool $autoPromote = true) | — |
rsvpWindow($opensAt = null, $closesAt = null) | — |
guestList(GuestList $guestList) · showCounts(bool $show = true) | — |
poster(UploadedFile|string|null $file = null, ?string $draft = null) | — |
category(Tag|int|string|null $category) · labels(iterable $labels) | — |
tier(array|string $name, ?Money $price = null, ?int $capacity = null, bool $hidden = false, ?string $key = null) | Repeatable. |
reminders(?array $offsets) | ≤ 5 ISO-8601 offsets, PT5M–P31D. |
meta(array $meta) | Private: never rendered by the API. |
by(?Model $actor) | Checks create and that the organizer allows the actor. |
toData() · create() · publish() | CreateEventData · a draft · create and publish in one transaction. |
Events::for($event)->edit() returns an EventEditor with the same setters for an existing event, plus title(): only the fields you touch change, save() runs the handle’s update() and toData() gives the UpdateEventData. The slug, category, labels, ticket types, poster and co-hosts have their own handle methods. The ticket type builder and the venue builder are covered on their own pages.
Model traits
| AttendsEvents member | Notes |
|---|---|
rsvp(Event $event): PendingRsvp | $user->rsvp($event)->going() — through the manager, so the fake records it. |
isGoingTo(Event $event): bool · rsvpFor(Event $event): ?Rsvp | — |
eventRsvps(): MorphMany | Every RSVP of the model. |
attendingEvents() · interestingEvents(): MorphToMany | Events it is going to / interested in. |
eventFeed(bool $includePrivate = false): IcsCalendar | Its personal calendar. |
| deletion hook | A force delete (or a delete without soft deletes) releases its RSVPs: seats back, waitlist promoted, rows removed. |
OrganizesEvents implements the Organizer contract with defaults and adds organizedEvents(), coHostedEvents() and organizeEvent($title) — see Organizers, co-hosts and invitations. Both traits go through the manager, so Events::fake() records what they do.
The PHP API trusts its caller
The facade always enforces the domain rules — seats, windows, phases, invitations, party limits. The policy is asked only when you name an actor with by(). In a controller, always pass the signed-in user: a handle without by() copied into a door controller would let any signed-in user check anyone in.
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.