---
title: "Ticket types and payments — Events for Laravel | Roundly"
description: "Ticket types priced in Money with capacity, sale windows and party limits — plus seat holds, your payment collector, the webhook and refunds."
url: https://roundly-consulting.com/open-source/docs/events-for-laravel/ticket-types
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)

# 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](https://roundly-consulting.com/open-source/docs/money-for-laravel.md); a paid seat is held while your payment provider collects, through a seam you implement.

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

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

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

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

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