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

RSVPs and the waitlist

$rsvp = Events::for($event)->rsvp($user)->going();                         // the event's single on-sale tier, or none
$rsvp = Events::for($event)->rsvp($user)->going('vip', guests: 2, note: 'Wheelchair access');
$rsvp = Events::for($event)->rsvp($user)->by($user)->interested();         // by() checks the rsvp policy
$rsvp = Events::for($event)->rsvp($user)->notGoing();

$user->rsvp($event)->going();      // AttendsEvents: the same call, through the manager

going() returns the Rsvp — read its status. There is one RSVP per attendee and event, and a new answer changes it. A note (≤ 500 characters over the API) is stored with the RSVP and shown to the attendee and managers; repeating the same answer with a new note saves the note.

StatusTakes seatsMeaning
goingyesA confirmed place — a check-in code is issued.
heldyesA paid seat waiting for its payment.
waitlistednoWaiting for seats, in order.
interestednoFollowing the event.
not_goingnoDeclined.
expirednoA hold that ran out.
$rsvp->status;                  // RsvpStatus: Going, Held, Waitlisted, Interested, NotGoing, Expired
$rsvp->isGoing();               // also isHeld(), isWaitlisted(), isInterested(), isCheckedIn()
$rsvp->partySize();             // 1 + guests
$rsvp->waitlistPosition();      // 1-based, or null

$user->isGoingTo($event);
$user->rsvpFor($event)?->waitlistPosition();
$user->attendingEvents()->upcoming()->get();
$user->interestingEvents()->get();
Event::query()->attendedBy($user)->get();

When an attendee may answer

  • A draft takes test answers from its managers only.
  • Cancelled and completed events take nothing (RsvpClosedException).
  • A postponed event takes interested and not going only.
  • A scheduled or rescheduled event freezes at its start and takes going only inside its RSVP window (rsvp_opens_at … rsvp_closes_at, else the start).
  • A private event needs an invitation (InvitationRequiredException, 403); answering going accepts it.

Guests and party limits

guests is extra people on one RSVP, so the party is 1 + guests: at most the event’s max_guests, or a ticket type’s perRsvp(min:, max:) limits instead (InvalidGuestCountException). More guests need free seats; fewer guests free seats and move the waitlist up.

  • An answer that names no ticket type on a going, held or waitlisted RSVP keeps the one it holds — including none: a seat taken before the event had ticket types stays a free seat through party changes.
  • The ticket type an RSVP holds is the attendee’s own: it is never re-checked as if newly chosen, so its sale may have ended or a manager may have given a hidden one, and the attendee still lowers the party or repeats the answer. Naming another type moves the RSVP under the usual rules.
  • A paid seat’s party changes by hand: on a going RSVP of a priced type the attendee’s own answer cannot change it (invalid_rsvp_transition, 409). A held RSVP’s party change makes a new hold with the new amount.
  • Checked-in guests keep their seats: a checked-in party cannot shrink below the guests already through the door.

Capacity and concurrency

Seats are counted on the event (capacity, null = unlimited) and on each ticket type. Every seat decision locks the event row first, then its ticket type rows in ascending id, then RSVP rows — lock first, then read — so under MySQL’s REPEATABLE READ no two requests can both see the last seat. Lifecycle and tags take the event row first too, so nesting their writes never inverts the order. Every write runs in Laravel’s transaction($callback, 3): a deadlock or a lost connection retries from a fresh row, up to three attempts; inside a transaction of your own the writes nest as savepoints and the retry is yours. Forty simultaneous going() on 25 seats give 25 going and 15 waitlisted, every time.

The counters — seats_taken, going_count, interested_count, waitlist_count and each ticket type’s seats_taken — move in the same transaction, and events:repair --counts recomputes them:

$counts = Events::for($event)->by($organizer)->counts();   // viewCounts, read fresh from the row

$counts->going;        // guests included
$counts->interested;
$counts->waitlisted;
$counts->seatsLeft;    // null = unlimited

$event->seatsLeft();
$event->isFull();

The waitlist

With waitlist on, a full event or ticket type waitlists instead of refusing (EventFullException or TierSoldOutException without one):

  • First fit in order: when seats free up, the oldest waitlisted party that fits moves up; a party too large for the free seats keeps its place, and a smaller one behind it may go first.
  • No queue-jumping: while a waitlisted party of that ticket type would fit the free seats, a newcomer joins the waitlist instead of taking them.
  • Automatic promotion (waitlist_auto_promote, on by default) stops events.waitlist.stop_promoting_before (PT1H) before the start and on cancellation. A promoted party on a paid type is held for events.payments.promotion_hold and told by the payment job once the checkout exists, with its link.
  • Every release promotes in the same transaction — including a lapsed hold that a payment webhook, a door confirmation or a manager’s change releases on its way. Seats freed while an event is postponed move up when it is rescheduled; switching waitlist_auto_promote back on fills free seats at once.
$waitlist = Events::for($event)->by($organizer)->waitlist();

$waitlist->all();                 // in order (waitlisted_at, then id) — viewWaitlist
$waitlist->position($user);       // 1-based or null — the actor's own, else viewWaitlist
$waitlist->promote(limit: 5);     // a manager's manual move-up — manageAttendees

Managers’ tools

$attendees = Events::for($event)->by($organizer)->attendees();   // manageAttendees
$attendees->add($user, tier: 'vip', guests: 1, overCapacity: true);   // comp seats past capacity, explicitly
$attendees->changeTier($user, 'floor');
$attendees->changeGuests($user, 2);
$attendees->confirm($user, $reference);    // a held seat, by hand
$attendees->release($user);                // a held seat, given back
$attendees->remove($user);
$attendees->find($user); $attendees->going(); $attendees->interested(); $attendees->checkedIn();

Every method takes the attendee model or its Rsvp; an RSVP of another event is refused. With by($actor) the writes check manageAttendees (checkIn and undoCheckIn check checkIn), and so do the reads: going() checks viewAttendees — the event’s guest_list decides — interested() viewInterested, checkedIn() checkIn, and find() lets an actor read their own RSVP and anyone else’s with manageAttendees. Without an actor nothing is checked. A comp seat from add() is going at once on any ticket type, hidden ones too, and past capacity only with overCapacity: true. changeTier() and changeGuests() release lapsed holds first; on a held RSVP they make a new hold with a new amount, whose checkout is requested on the queue.

Deleted attendees

AttendsEvents releases a force-deleted attendee’s RSVPs — seats returned, waitlist promoted, rows removed. A soft delete keeps them and their seats, so such a ticket still checks in. Mass deletes fire no model events: run events:repair --orphans after one. Force-deleting an attendee inside your own transaction keeps each of their events locked until you commit; two such deletions running at once can deadlock on two shared events, and retrying is your transaction’s job (DB::transaction($callback, 3)).

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.