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

Models and schema

Seven tables, seven models. Every model stores its timestamps in UTC whatever app.timezone says. Write through the facade, never with save() on these models — the handle keeps seats, counters, notices, locks and the fake right. The models carry reads, relations and scopes.

Tables

events-migrations publishes seven anonymous, forward-only migrations (no down()), timestamped on publish and never auto-loaded. Morph pairs are <name>_type (a string) + <name>_id (the configured key type). Translatable and slug columns are jsonb on PostgreSQL and json on MySQL.

TableConfigHolds
event_venuestables.venuesTranslatable name, unique slug, owner morph (null = shared), street, city, region, postal_code, country_code (ISO 3166-1 alpha-2), formatted_address, latitude / longitude (decimal 10,7, indexed for the bounding box), timezone, capacity (informational), geocoded_at, private meta. Soft deletes.
eventstables.eventsuuid (route key, share key, calendar UID), per-locale slug, title, description, organizer morph, venue_id (restrict), encrypted online_url, status, visibility, attendance_mode, timezone, starts_at / ends_at (UTC), doors_open_at, previous_starts_at, starts_on / ends_on (local days), all_day, the lifecycle stamps, moved_online_at, capacity, max_guests, waitlist flags, RSVP window, guest_list, show_counts, reminders, counters (seats_taken, going_count, interested_count, waitlist_count), sequence, private meta. Soft deletes.
event_admission_tierstables.tiersevent_id (cascade), key (unique per event, trashed included), translatable name and description, price + price_currency (null = free), capacity, seats_taken, sale window, min_party / max_party, hidden, position. Soft deletes.
event_rsvpstables.rsvpsevent_id (cascade), attendee morph (one RSVP per attendee and event), tier_id (restrict), status, guests, note, waitlisted_at (the queue order), held_until, hold_key, payment_reference (unique), payment_url, amount, confirmed_at, responded_at, encrypted check_in_code + its hash (unique), checked_in_at, checked_in_guests, check_in_method, checked_in_by / added_by / removed_by morphs, private meta. No soft deletes — not_going is its deleted.
event_hoststables.hostsevent_id (cascade), host morph (unique per event), added_by morph, created_at. Removing a co-host deletes the row.
event_invitationstables.invitationsevent_id (cascade), invitee morph (unique per event), invited_by morph, status, message, token_hash (only the hash is stored), responded_at. No expiry column — a link expires at the event’s current start.
event_reminder_deliveriestables.reminder_deliveriesevent_id (cascade), offset, starts_at, dispatched_at, recipients. Unique (event_id, offset, starts_at) — the insert is the claim that sends a reminder once.

The events table is indexed for listings (visibility, status, starts_at), start, end, local days, venue and organizer. The per-locale slug indexes cover translatable.locales at migration time; add a later locale’s index with php artisan sluggable:indexes followed by the event model’s class name.

Models

ModelConfigNotes
Models\Eventmodels.eventHasMedia, LifecycleSubject, Sluggable, Taggable, Translatable; hides organizer_type, online_url and meta.
Models\Venuemodels.venueTranslatable name, HasSlug, geolocation’s HasLocation (the coordinates attribute); hides owner_type and meta.
Models\AdmissionTiermodels.tierTranslatable name and description; price cast with money-for-laravel’s AsMoney.
Models\Rsvpmodels.rsvpHides the morph types, check_in_code, its hash, payment_url and meta; amount is Money, check_in_code encrypted.
Models\EventHostfinalevent(), host(), addedBy().
Models\EventInvitationfinalevent(), invitee(), invitedBy(), isPending(), expiresAt() (the event’s current start; null while postponed) and $plainToken — set only on the instance send() returns, never stored.
Models\ReminderDeliveryfinalevent().

The Event model

Relations: venue() (trashed venues included), organizer() (MorphTo; null once its row is gone), tiers() (ordered by position, then id), rsvps(), hosts() and invitations(). Casts: status to the configured lifecycle’s state enum; visibility, attendance_mode and guest_list to their enums; online_url encrypted; every timestamp a CarbonImmutable in UTC.

MemberReturnsNotes
$event->phaseEventPhaseThe phase of the current state.
$event->category / $event->labels?Tag / Collection<Tag>In memory when tags is loaded.
$event->poster?Media—
$event->distance_km?floatSet by withDistance().
posterUrl(string $variant = '')?stringThe variant once generated, else the original, else events.poster.fallback_url; never throws.
startsAtLocal() / endsAtLocal()CarbonImmutableIn the event’s timezone.
isUpcoming() / isOngoing() / isPast()bool—
seatsLeft()?intnull = unlimited.
isFull()bool—
tagScope()?ModelThe organizer with events.tags.scope = organizer, else null.
toIcs(?Model $viewer = null)IcsCalendar= Events::for($event)->ics($viewer).
toJsonLd() / renderJsonLd()?array / HtmlStringThe schema.org data, or the script tag (empty when not emitted).

Query scopes

Every API filter is one of these scopes, so PHP and HTTP filter identically:

ScopeDoes
visibleTo(?Model $viewer)The policy’s view as a query — see Privacy and authorization.
listed()Public visibility.
published()Announced: every phase but Draft.
active()Scheduled or Rescheduled.
inPhase(EventPhase ...$phases)Mapped to the configured lifecycle’s states.
atVenue(Venue|int ...$venues)—
online() · inPerson() · withAttendanceMode(AttendanceMode ...$modes)inPerson() is offline or mixed.
organizedBy(Model $organizer)Co-hosted events excluded.
managedBy(Model $user)The events $user manages, drafts included.
attendedBy(Model $attendee) · interestedBy(Model $attendee)By RSVP status.
free()No ticket type with a price.
withSeatsAvailable()Seats left, or no capacity.
search(string $term)Title or description contains the term — any locale, case-insensitive, LIKE wildcards escaped; blank adds nothing.
popular()Most going first, then the soonest.
upcoming() · ongoing() · past() · between() · startingBetween() · onLocalDate()See Time and timezones.
inCategory() · labelled() · uncategorized()See Categories and labels.
near() · withDistance() · orderByDistance()See Venues and near-me search.
use RoundlyConsulting\Events\Models\Event;

Event::query()->visibleTo($user)->listed()->active()->free()->withSeatsAvailable()
    ->search('jazz')->popular()->paginate();

Event::query()->managedBy($user)->get();          // the events $user manages, drafts included
Event::query()->organizedBy($team)->upcoming()->get();
Event::query()->atVenue($venue)->inPerson()->get();

Route-model binding uses the uuid (getRouteKeyName()); a malformed uuid is a 404 without a query. Binding applies no visibility rule — authorize view. A force delete removes the poster files (media-library removes nothing on its own); a soft delete keeps them.

Venue, AdmissionTier and Rsvp

  • Venue — owner() and events() relations, formattedAddress(), the coordinates attribute (?Coordinates), the scopes ownedBy($owner) and near($point, $km), plus geolocation’s withinRadius() (the bounding box alone).
  • AdmissionTier — event() (trashed included); isFree(), isOnSale() (inside the sale window now) and seatsLeft(); price is a Money or null.
  • Rsvp — event(), attendee(), tier() (trashed included) and checkedInBy(); isGoing(), isHeld(), isWaitlisted(), isInterested(), isCheckedIn(), partySize(), waitlistPosition(), paymentIdempotencyKey() (ev-hold-…), checkInCode() and checkInQr().

Swapping a model

namespace App\Models;

final class Event extends \RoundlyConsulting\Events\Models\Event
{
    // your relations, casts, traits
}

// config/events.php
'models' => ['event' => App\Models\Event::class],

Four models are swappable — event, venue, tier and rsvp; EventHost, EventInvitation and ReminderDelivery are final. The package resolves every model through this seam — relations, queries, the policy registration, the tag types and the factories — so your subclass is the one created and returned. A class that is not a subclass of the package’s throws the toolkit’s InvalidConfigurationException, naming the key.

Enums

EnumCasesHelpers
EventStatusDraft, Scheduled, Postponed, Rescheduled, Cancelled, CompletedThe shipped lifecycle’s states; phase(), color() (gray, green, amber, blue, red, zinc).
EventPhaseDraft, Scheduled, Postponed, Rescheduled, Cancelled, CompletedWhat every package rule reads, whatever your states are; isTerminal(), isActive(), isPublished().
EventVerbPublish, Unpublish, Postpone, Reschedule, Cancel, CompleteThe handle’s status verbs.
VisibilityPublic, Unlisted, Private—
AttendanceModeOffline, Online, MixedschemaOrg(): the schema.org enumeration URL.
GuestListHidden, Attendees, Public—
RsvpStatusGoing, Held, Waitlisted, Interested, NotGoing, ExpiredtakesSeats() (Going, Held); color().
RsvpResponseGoing, Interested, NotGoingAn attendee’s answer.
CheckInMethodCode, Manual—
InvitationStatusPending, Accepted, Declined, Revoked—
EventTagTypeCategory (events:category), Label (events:label)—
EventOperationOne case per recorded write (50)See Testing.

Every enum except EventOperation, EventTagType and FindingSeverity uses enums-for-laravel’s Helpers: label() (translated, English and Slovak), labels(), values(), options(), toOptions(), fromName(), tryFromName(), is(), isIn(), validationRule() and the rest of that trait. English labels include “Awaiting payment” for RsvpStatus::Held, “In person and online” for AttendanceMode::Mixed and “By hand” for CheckInMethod::Manual:

use RoundlyConsulting\Events\Enums\RsvpStatus;

RsvpStatus::Waitlisted->label();              // "On the waitlist" ("Na čakacej listine" in sk)
$rsvp->status->takesSeats();                   // going or held
$event->phase->isActive();                     // RSVPs and check-in run

Data objects

Every DTO is final readonly and constructor-promoted. Inputs:

DTOFields
CreateEventDatatitle, organizer, schedule (ScheduleData), and every builder field: description, venue, onlineUrl, attendanceMode, visibility, capacity, maxGuests, waitlist, waitlistAutoPromote, rsvpOpensAt, rsvpClosesAt, guestList, showCounts, reminders, slug, coHosts, tiers (list<TierData>), poster, posterDraft, category, labels, meta, actor.
ScheduleDatastartsAt, endsAt, duration, allDayFrom, allDayThrough, doorsOpenAt, timezone — all null by default; isAllDay().
UpdateEventDataEvery event field, each defaulting to Support\NotGiven::Value (unchanged); null clears a nullable one; movesStart().
VenueData / UpdateVenueDataname, address (AddressData), coordinates, timezone, capacity, owner, geocode, meta, actor / the same fields as NotGiven, where address replaces all six address columns.
AddressDatastreet, city, region, postalCode, country, formatted
TierData / UpdateTierDataname, key, price (Money or a decimal string), capacity, saleStartsAt, saleEndsAt, minParty (1), maxParty, hidden, position, description / the same as NotGiven.
RsvpRequestattendee, response (RsvpResponse), tier, guests, note, actor
EventFeedQuerycategory, label, organizer, venue, name (the calendar name).
SweepOptions / RepairOptionslimit, chunk (100) / counts, orphans, dryRun, chunk (100)

Results:

DTOFields
CheckInResultrsvp, attendee, firstCheckIn, guests, checkedInAt, method
EventCountsgoing (guests included), interested, waitlisted, seatsLeft
PaymentRequestreference, url, expiresAt — what your collector returns
SweepResultevents, expiredHolds, promoted, reminders, notified
RepairReporteventsChecked, countersFixed, orphansReleased, dryRun
DoctorFindingseverity (FindingSeverity), check, message; toArray()
PosterVariantname, width, height — one entry of events.poster.variants

Contracts

ContractMethodsDefault and swap
OrganizereventOrganizerName(), eventOrganizerUrl(), allowsEventManagementBy(Model $user), scopeManagingEventsFor(Builder $query, Model $user)On your organizer models; Concerns\OrganizesEvents gives defaults.
CollectsAdmissionPaymentrequest(Rsvp $rsvp, Money $amount): ?PaymentRequestPayments\NoPaymentCollector; events.payments.collector.
EventUrlsevent(Event $event), invitation(string $token), rsvp(Rsvp $rsvp) — each ?stringSupport\TemplateEventUrls; events.urls.resolver.
CheckInCodesissue(Rsvp $rsvp), extract(string $input), hash(string $code)Support\RandomCheckInCodes, bound with bindIf — bind your own in a service provider.
EventLifecycletransitionFor(EventVerb $verb): ?stringLifecycle\EventLifecycle; events.lifecycle.
EventStatephase(): EventPhaseImplemented by your lifecycle’s state enum (EventStatus ships).

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.