---
title: "Models and schema — Events for Laravel | Roundly"
description: "The seven tables and seven models, what the Event model reads, its query scopes, the enums, DTOs and contracts, and swapping a model."
url: https://roundly-consulting.com/open-source/docs/events-for-laravel/models-and-schema
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Events for Laravel](https://roundly-consulting.com/open-source/docs/events-for-laravel.md)

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

| Table | Config | Holds |
| --- | --- | --- |
| `event_venues` | `tables.venues` | Translatable 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. |
| `events` | `tables.events` | uuid (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_tiers` | `tables.tiers` | event\_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_rsvps` | `tables.rsvps` | event\_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_hosts` | `tables.hosts` | event\_id (cascade), host morph (unique per event), added\_by morph, created\_at. Removing a co-host deletes the row. |
| `event_invitations` | `tables.invitations` | event\_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_deliveries` | `tables.reminder_deliveries` | event\_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

| Model | Config | Notes |
| --- | --- | --- |
| `Models\Event` | `models.event` | HasMedia, LifecycleSubject, Sluggable, Taggable, Translatable; hides organizer\_type, online\_url and meta. |
| `Models\Venue` | `models.venue` | Translatable name, HasSlug, geolocation’s HasLocation (the coordinates attribute); hides owner\_type and meta. |
| `Models\AdmissionTier` | `models.tier` | Translatable name and description; price cast with money-for-laravel’s AsMoney. |
| `Models\Rsvp` | `models.rsvp` | Hides the morph types, check\_in\_code, its hash, payment\_url and meta; amount is Money, check\_in\_code encrypted. |
| `Models\EventHost` | final | event(), host(), addedBy(). |
| `Models\EventInvitation` | final | event(), invitee(), invitedBy(), isPending(), expiresAt() (the event’s current start; null while postponed) and $plainToken — set only on the instance send() returns, never stored. |
| `Models\ReminderDelivery` | final | event(). |

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

| Member | Returns | Notes |
| --- | --- | --- |
| `$event->phase` | `EventPhase` | The phase of the current state. |
| `$event->category / $event->labels` | `?Tag / Collection<Tag>` | In memory when tags is loaded. |
| `$event->poster` | `?Media` | — |
| `$event->distance_km` | `?float` | Set by withDistance(). |
| `posterUrl(string $variant = '')` | `?string` | The variant once generated, else the original, else events.poster.fallback\_url; never throws. |
| `startsAtLocal() / endsAtLocal()` | `CarbonImmutable` | In the event’s timezone. |
| `isUpcoming() / isOngoing() / isPast()` | `bool` | — |
| `seatsLeft()` | `?int` | null = unlimited. |
| `isFull()` | `bool` | — |
| `tagScope()` | `?Model` | The organizer with events.tags.scope = organizer, else null. |
| `toIcs(?Model $viewer = null)` | `IcsCalendar` | \= Events::for($event)->ics($viewer). |
| `toJsonLd() / renderJsonLd()` | `?array / HtmlString` | The 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:

| Scope | Does |
| --- | --- |
| `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. |

```php
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

```php
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

| Enum | Cases | Helpers |
| --- | --- | --- |
| `EventStatus` | Draft, Scheduled, Postponed, Rescheduled, Cancelled, Completed | The shipped lifecycle’s states; phase(), color() (gray, green, amber, blue, red, zinc). |
| `EventPhase` | Draft, Scheduled, Postponed, Rescheduled, Cancelled, Completed | What every package rule reads, whatever your states are; isTerminal(), isActive(), isPublished(). |
| `EventVerb` | Publish, Unpublish, Postpone, Reschedule, Cancel, Complete | The handle’s status verbs. |
| `Visibility` | Public, Unlisted, Private | — |
| `AttendanceMode` | Offline, Online, Mixed | schemaOrg(): the schema.org enumeration URL. |
| `GuestList` | Hidden, Attendees, Public | — |
| `RsvpStatus` | Going, Held, Waitlisted, Interested, NotGoing, Expired | takesSeats() (Going, Held); color(). |
| `RsvpResponse` | Going, Interested, NotGoing | An attendee’s answer. |
| `CheckInMethod` | Code, Manual | — |
| `InvitationStatus` | Pending, Accepted, Declined, Revoked | — |
| `EventTagType` | Category (events:category), Label (events:label) | — |
| `EventOperation` | One 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:

```php
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:

| DTO | Fields |
| --- | --- |
| `CreateEventData` | title, 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. |
| `ScheduleData` | startsAt, endsAt, duration, allDayFrom, allDayThrough, doorsOpenAt, timezone — all null by default; isAllDay(). |
| `UpdateEventData` | Every event field, each defaulting to Support\\NotGiven::Value (unchanged); null clears a nullable one; movesStart(). |
| `VenueData / UpdateVenueData` | name, address (AddressData), coordinates, timezone, capacity, owner, geocode, meta, actor / the same fields as NotGiven, where address replaces all six address columns. |
| `AddressData` | street, city, region, postalCode, country, formatted |
| `TierData / UpdateTierData` | name, key, price (Money or a decimal string), capacity, saleStartsAt, saleEndsAt, minParty (1), maxParty, hidden, position, description / the same as NotGiven. |
| `RsvpRequest` | attendee, response (RsvpResponse), tier, guests, note, actor |
| `EventFeedQuery` | category, label, organizer, venue, name (the calendar name). |
| `SweepOptions / RepairOptions` | limit, chunk (100) / counts, orphans, dryRun, chunk (100) |

Results:

| DTO | Fields |
| --- | --- |
| `CheckInResult` | rsvp, attendee, firstCheckIn, guests, checkedInAt, method |
| `EventCounts` | going (guests included), interested, waitlisted, seatsLeft |
| `PaymentRequest` | reference, url, expiresAt — what your collector returns |
| `SweepResult` | events, expiredHolds, promoted, reminders, notified |
| `RepairReport` | eventsChecked, countersFixed, orphansReleased, dryRun |
| `DoctorFinding` | severity (FindingSeverity), check, message; toArray() |
| `PosterVariant` | name, width, height — one entry of events.poster.variants |

## Contracts

| Contract | Methods | Default and swap |
| --- | --- | --- |
| `Organizer` | eventOrganizerName(), eventOrganizerUrl(), allowsEventManagementBy(Model $user), scopeManagingEventsFor(Builder $query, Model $user) | On your organizer models; Concerns\\OrganizesEvents gives defaults. |
| `CollectsAdmissionPayment` | request(Rsvp $rsvp, Money $amount): ?PaymentRequest | Payments\\NoPaymentCollector; events.payments.collector. |
| `EventUrls` | event(Event $event), invitation(string $token), rsvp(Rsvp $rsvp) — each ?string | Support\\TemplateEventUrls; events.urls.resolver. |
| `CheckInCodes` | issue(Rsvp $rsvp), extract(string $input), hash(string $code) | Support\\RandomCheckInCodes, bound with bindIf — bind your own in a service provider. |
| `EventLifecycle` | transitionFor(EventVerb $verb): ?string | Lifecycle\\EventLifecycle; events.lifecycle. |
| `EventState` | phase(): EventPhase | Implemented 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/events-for-laravel.md)
