Ticket types and payments
The package has no orders and takes no money. A ticket type (a tier) is an admission option with an optional price from Money for Laravel; a paid seat is held while your payment provider collects, through a seam you implement.
use RoundlyConsulting\Money\Money;
Events::for($event)->tiers()->add('Supporter', price: Money::ofMajor('15', 'EUR'), capacity: 30, key: 'supporter');
Events::for($event)->tiers()->make(['en' => 'Early bird', 'sk' => 'Včasná vstupenka'])
->price('9.50') // a Money, or a decimal in events.tiers.default_currency
->capacity(50)
->onSale(from: '2026-10-01 09:00', until: '2026-10-31 23:59')
->perRsvp(min: 1, max: 4) // party limits replace the event's max_guests
->hidden() // assignable by managers only, never listed for attendees
->position(1)->key('early')->description('…')
->create();Managing ticket types
use RoundlyConsulting\Events\DataTransferObjects\UpdateTierData;
$tiers = Events::for($event)->by($organizer)->tiers(); // writes check manageTiers
$tiers->update('supporter', new UpdateTierData(capacity: 40)); // a model, id or key of this event
$tiers->reorder(['early', 'supporter', 'free']); // every live ticket type, first to last
$tiers->remove('early'); // soft; refused while RSVPs use it or a waitlist waits for it
$tiers->restore('early');
$tiers->all(); // live ones in order, hidden included (with an actor: only if they may manageTiers)
$tiers->available(); // visible, on sale, with seats (or a waitlist to join)
$tier->isFree();
$tier->isOnSale();
$tier->seatsLeft(); // null = unlimited- Keys match ^[a-z0-9][a-z0-9-]{0,63}$, are unique per event and never suffixed: a taken key throws tier_key_taken, and a trashed ticket type’s key names restore() in the message. Without one, the key is the name’s slug in the fallback locale.
- A price is a Money or a decimal string read in events.tiers.default_currency (blank: money.default_currency). A zero price is stored as free, a negative one is invalid_tier, and ->free() on the builder clears a price.
- Party limits need min ≥ 1 and max ≥ min and replace the event’s max_guests. A sale window must end after it starts; wall-clock strings are read in the event’s timezone.
- going() resolves a ticket type by model, id or key; without one, the event’s single on-sale type is picked — with several, TierRequiredException.
- TierNotOnSaleException (409) before sale_starts_at or after sale_ends_at — for a type the attendee newly picks. The one an RSVP already holds stays the attendee’s after its sale ends, and a hidden one a manager gave stays usable by its holder. TierSoldOutException (409) when its capacity is taken and the event has no waitlist.
- remove() is a soft delete, refused while RSVPs use the type or a waitlist waits for it.
Holds
Answering going on a priced ticket type holds the seats (status held) for events.payments.hold_for (PT30M) — events.payments.promotion_hold (PT24H) for a seat freed from the waitlist — and calls your collector after the commit. A hold never outlives the start: it is capped at the start when made, and a reschedule (or a draft’s new start) to an earlier time shortens every live hold to the new start.
Your payment collector
use RoundlyConsulting\Events\Contracts\CollectsAdmissionPayment;
use RoundlyConsulting\Events\DataTransferObjects\PaymentRequest;
use RoundlyConsulting\Events\Models\Rsvp;
use RoundlyConsulting\Money\Money;
final class StripeCheckout implements CollectsAdmissionPayment
{
public function request(Rsvp $rsvp, Money $amount): ?PaymentRequest
{
// The idempotency key is one per hold: two requests for the same hold (the after-commit call
// and a retry, the promotion job and your own call) get the provider's one checkout.
$session = /* create a checkout session for $amount, metadata rsvp id,
idempotency key $rsvp->paymentIdempotencyKey() */;
return new PaymentRequest(reference: $session->id, url: $session->url, expiresAt: $rsvp->held_until);
}
}
// config/events.php
'payments' => [
'collector' => App\Payments\StripeCheckout::class,
'hold_for' => 'PT30M',
'promotion_hold' => 'PT24H',
],- For the attendee’s own answer the collector runs synchronously after the commit, so the checkout URL is on the returned RSVP ($rsvp->payment_url). A hold someone else’s action created — a promotion off the waitlist, a manager’s changeTier() or changeGuests() on a held RSVP — is requested on the queue (Jobs\RequestAdmissionPaymentJob, after commit): a failing collector never fails that action; it is reported and RsvpPaymentRequestFailed fires.
- PaymentRequest(reference, ?url, ?expiresAt): the reference is stored on the RSVP (payment_reference, unique) and is what confirmPayment() looks up; the url becomes payment_url, shown to the attendee while the seat is held.
- $rsvp->paymentIdempotencyKey() (ev-hold-…) is a fresh random key for every new hold and every refresh — a party or ticket type change makes a new hold, a new amount and a new checkout. Pass it as your provider’s idempotency key.
- A hold that changes while the collector is creating its checkout keeps nothing of it: RsvpHeld is not fired, requestPayment() returns null and RsvpPaymentRequestDiscarded reports the checkout so you can void it.
- The collector’s exception propagates for the attendee’s own answer; the hold stays and expires.
- Without a collector (NoPaymentCollector), listen to RsvpHeld(Rsvp, Money $amount, ?PaymentRequest) and start the payment yourself; events:doctor warns about paid ticket types with neither. The collector is called inside no package transaction — inside one of yours it runs after your commit.
Confirming — your webhook
Events::rsvps()->confirmPayment($session->id); // your webhook: held → going, idempotent
Events::rsvps()->confirm($rsvp, $reference); // when you know the RSVP
Events::for($event)->attendees()->confirm($user); // a manager, by hand ("paid at the door")
Events::rsvps()->release($rsvp); // give the held seats back now
Events::rsvps()->requestPayment($rsvp); // ask the collector (again) — once per hold- There is no route for confirmation: payment confirmation is server-side only, and a reference confirms only its own RSVP. Repeating it on a going RSVP is an idempotent no-op at any time.
- A late payment — the hold expired — before the start: seats still free, it becomes going (AttendeeConfirmed with late: true); none left, RsvpHoldExpiredException — refund it. From starts_at on, and for a cancelled or completed event, a late payment is RsvpClosedException — refund it.
- A manager’s confirm() — paid at the door — is not bound to the start, but refuses a cancelled event.
- A declined hold is never revived by a payment, and a superseded reference (after a ticket type change) is not found. A seat that becomes going any other way — a switch to a free type while the checkout is open, a comp seat over a lapsed hold — drops the old payment_reference, payment_url and amount, so that checkout’s webhook is not found: refund it.
- Expiry is lazy (on the next write to the event) and swept by events:sweep, exactly once either way; RsvpHoldExpired fires and the waitlist moves up.
- Cancelling an event expires every hold — EventCancelled::$releasedHolds lists them — and RsvpCancelled(Rsvp, bool $wasPaid) flags the paid RSVPs to refund.
- A going RSVP on a priced type changes its party by hand only (attendees()->changeGuests()); the attendee’s own answer is invalid_rsvp_transition, because there is no price difference to collect or refund.
Voiding a discarded checkout
use Illuminate\Support\Facades\Event as EventBus; // Models\Event shadows Laravel's facade name
use RoundlyConsulting\Events\Events\RsvpPaymentRequestDiscarded;
EventBus::listen(RsvpPaymentRequestDiscarded::class, function (RsvpPaymentRequestDiscarded $discarded): void {
$stripe->checkout->sessions->expire($discarded->paymentRequest->reference); // nobody pays for a gone seat
});A known edge
The reference is stored after request() returns. A collector that charges synchronously inside request() — an off-session card — can see its provider’s webhook arrive before that, and confirmPayment() then answers RsvpNotFoundException. For such a provider, let the webhook be retried (answer with a retryable status) instead of refunding on the first not-found, or confirm from request()’s own result.
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.