Skip to main content

Documentation

No results found.
Features

Event Ticketing

Event Ticketing sells tickets to events straight from the event page: paid or free ticket types, capacity limits that can't oversell, an embedded Stripe checkout, emailed tickets with QR codes, one-tap door check-in from any phone, and atte...

Event Ticketing sells tickets to events straight from the event page: paid or free ticket types, capacity limits that can't oversell, an embedded Stripe checkout, emailed tickets with QR codes, one-tap door check-in from any phone, and attendee exports. Every buyer lands in the CRM, so the person who bought a ticket today is reachable by every follow-up tool the site has.


The problem

An event's tickets usually live on Eventbrite or a Facebook event — a third-party brand between the business and its own audience, a per-ticket fee on every sale, and an attendee list that belongs to someone else's platform. The website announces the event; the money and the relationship happen elsewhere.

The fix

A self-contained feature module under app/Features/EventTicketing/ puts ticket sales on the site's own event pages: ticket types with prices, capacity, and sales windows attach to any event from the Events Calendar; visitors buy (or RSVP free) without leaving the page flow; tickets with QR codes are emailed instantly; and door staff check people in by pointing a phone camera at the QR. Buyers create CRM contacts automatically (source Tickets) and completed orders record a ticket_order Analytics goal with the revenue.

Like every addon, it's structured as a feature module gated by feature:event_ticketing middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations. Nothing is materialized into the page tree — the buy UI is a feature-gated section of the event detail layout that renders only while the feature is enabled.

What visitors see

On an event page with tickets on sale, a Get Tickets section lists each ticket type with its price ("Free" or the amount) — with the regular price struck through and an "Early bird until…" note while an early-bird tier is live — a low-stock note when 10 or fewer seats remain, and a Sold out badge (with a Join waitlist link) when none do. An optional promo code field validates live and shows the discount. The visitor picks quantities, enters name and email, and:

  • Free tickets (RSVP) issue instantly — no payment step.
  • Paid tickets go to an embedded Stripe checkout. The selected seats are held while they pay and released automatically if checkout is abandoned, so a slow payer can't lose their seats and an abandoner can't block a sellout.
  • Recurring events (weekly classes, monthly meetups) add a date picker — the chosen occurrence is stamped on the order and its tickets.

Sales stop when the event is over, whether or not a ticket type has its own sales window (most never get one). A one-off event sells until its end — else its start — and a date-only or all-day event until the end of that day; a running festival keeps selling until it closes. A recurring event offers each date until that date is over (its start plus the event's length) and stops once none are left — however long ago the series began: a daily class started two years back still offers its next 50 dates. The event's time is read in its own zone, else the site's — never UTC, which would have closed a Central-time evening event five hours early. Past that point the widget says This event has ended instead of showing the form, and the server refuses the order too — a ticket form loaded before the end can't start a checkout after it, and a recurring order can't name a date already gone. A checkout already open when the event ends can still be paid until its hold runs out (the checkout hold, 35 minutes by default): nothing re-checks at payment, since refusing once the money has moved would mean a refund. The event's manage page badges its ticket types Event ended, the waitlist stops promising seats, and the AI chat says the event is over. An event with no date never ends on its own.

An order keeps its event's clock time. The order stores the event's wall clock (the picked date, for a recurring event) and the moment that meant when it was bought — the UTC instant the API's event_starts_at and the ticket_order.paid webhook send. The order page, the emails, the calendar invite and the wallet pass re-read the wall clock in the event's own zone, else the site's. So when the site's timezone changes (Settings → Business, or cms:set-site-timezone), every order for an event still to come is re-stamped to the new zone in the same save, and the API agrees with the invite; an order for an event already over keeps the moment it really happened. An event with its own zone doesn't move. Logic: TicketOrderStartSync.

Every completed order gets:

  • A confirmation email with the order summary, the ticket codes, an attached .ics calendar invite (the event's own end and all-day flag, written exactly as the public calendar feed writes the event), and a button to the ticket page.
  • A ticket page (tokenized link, no login) showing each ticket as a card: the door code, a QR code, its live status, a Change attendee control (reassign a ticket to the friend actually using it — the door list shows the new name), and — when wallet credentials are configured — Add to Apple Wallet / Save to Google Wallet buttons. The email link always comes back here — it's the buyer's ticket wallet at the door.

Mixed orders work naturally: two paid GA tickets and a free kids' ticket are one checkout, one email, three QR codes.

QR check-in at the door

Each ticket's QR encodes a check-in URL inside the dashboard — so door staff need no special app: point any phone camera at the QR, log in once as a Manager, and the check-in page opens with the event, ticket type, and buyer name and one big Check in button.

  • Checking in is atomic — two staffers scanning the same ticket at the same moment can't both admit it.
  • A second scan shows a loud "Already checked in" warning with when it was first scanned.
  • Refunded (void) tickets show a red "do not admit" rejection.
  • Mistakes are one tap to undo, and every ticket can also be checked in manually from the attendee list (search by code, name, or email).

The camera scanner

Dashboard → Ticketing → Scan turns any staff phone into a dedicated scanner: a live camera view (native BarcodeDetector — Chrome/Edge/Android; requires HTTPS) reads ticket QRs continuously and checks each in on the spot with a green/amber/red result card, a running session tally (checked in / duplicates / rejected), and today's check-ins per staffer. Scans made while the venue wifi drops are queued in the browser and synced automatically when the connection returns. Manual code entry sits under the camera for browsers that can't scan in-page. Per-staff totals also show on each event's manage page (every check-in records who scanned it).

What the dashboard gets

Three pages under Dashboard → Ticketing (Manager and up; Settings is Admin-only):

  • Events & Tickets — every event with ticket types, showing sold/capacity, check-ins, and revenue at a glance, plus a picker to start selling tickets on another event, and the promo codes manager (percent or amount off, optional per-event scope, expiry, and redemption caps — redemptions count completed orders only). Each event's manage page holds the ticket type editor (name, price, early-bird price + deadline, capacity, max per order, sales window, on/off), stat tiles, per-staff check-in totals, the attendee list with search and check-in/undo, the event's orders, and an Add to Marketing action that pushes every buyer into an "Attendees: {event}" CRM group + the Marketing subscribers (campaign-targetable via the crm_group audience). The buyers didn't opt in themselves, so new subscriber rows say so — source Ticket buyers, added by staff, with a consent record naming who added them (an existing subscriber keeps their own) — and an SMS campaign to the group still reaches only buyers with their own recorded SMS opt-in.
  • Orders — all orders across events with status tabs and search. The detail drawer shows line items, ticket codes and statuses, and the Stripe payment reference, with four actions: Resend confirmation, Transfer to a different email (repoints the order and resends the tickets), Open ticket page, and Refund (full refund through Stripe — the tickets are voided, their seats reopen for sale, and the waitlist notifier emails anyone waiting).
  • Settings — currency (defaults to the shop's), checkout hold minutes, the Stripe webhook signing secret, sender name/email overrides, an optional owner-notification address for every new order, and the wallet pass credentials (Google Wallet issuer id + service-account JSON; Apple Wallet pass certificate .p12 + password + Pass Type/Team IDs + Apple's WWDR PEM — all optional, the buttons only appear once configured).

Attendees export to CSV per event (code, type, attendee, buyer, status, check-in time, event date, order, paid) — spreadsheet-injection-hardened like every other export in the CMS.

Capacity & holds — why it can't oversell

Capacity is claimed when checkout starts, not when payment lands: starting a checkout atomically reserves the requested seats against the ticket type's counter, and the reservation expires together with the Stripe checkout session (default 35 minutes, configurable). Whichever of the webhook or the return page lands first completes the order — the other is a no-op. If checkout is abandoned, the expiry webhook (with a five-minute cron sweep as backstop) deletes the pending order and releases its seats. The sweep expires each lapsed order's Stripe session first; a session Stripe won't expire because the buyer already completed it (its completed event still being retried) is settled instead — completed with its tickets, or held as processing for a bank payment — and keeps its seats. Two buyers racing for the last seat can never both win, and a paid order can never arrive to find its seats gone.

Stripe setup

Ticketing shares the site-wide Stripe keys from Settings → API Keys (the same pair Ecommerce, Booking, and Donations use). The only ticketing-specific piece is the webhook: create an endpoint in the Stripe dashboard pointing at /tickets/stripe/webhook (the Settings page shows the full URL), subscribe it to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired, and charge.refunded, and paste its signing secret into Dashboard → Ticketing → Settings. Free tickets work with no Stripe configuration at all.

Bank payments (ACH)

A buyer can pay by US bank account (ACH) when your Stripe account offers it — but only for an event at least four days away (DelayedPayment::MIN_LEAD_DAYS, measured from the event's real start: its wall clock read in the event's zone, else the site's). A debit can take that long to settle or bounce, and seats must not be held for the door on money that may never arrive; sooner than that, checkout offers cards and wallets only.

  • While it clears the order is Payment processing (status processing): its seats stay claimed and its hold expiry is cleared, so neither the expiry sweep nor a new checkout by the same buyer releases them, and any gift card / credit / points it applied stay reserved. No tickets are issued and no confirmation is sent yet. The return page hands the buyer to their order page, which shows the processing notice instead of tickets — never the checkout again.
  • When it clears (checkout.session.async_payment_succeeded) the order completes exactly like a card order — tickets, confirmation, credits captured, ticket_order.paid — once.
  • If it bounces (checkout.session.async_payment_failed) the order is canceled with payment_failed_at (badged Payment failed), its seats are released (the waitlist notifier picks them up), its credit reservation is dropped, and the buyer gets one email saying the payment didn't go through — quoting the amount the bank was asked for, after any gift card, credit or points — with a Try again button back to the event.
  • If Stripe never says how it ended — the webhook isn't subscribed to the two checkout.session.async_payment_* events, or Stripe gave up delivering them — the payment is checked against Stripe once it has been processing for a day (php artisan payments:reconcile-processing, every six hours) and settled or failed exactly as the webhook would have, with a warning in the log.

Processing and bounced orders appear in the dashboard's Orders list (their own Processing / Canceled tabs), on the buyer's account page, and over the API (status=processing / canceled) — never in revenue, which counts issued tickets only.

Gift cards, account credit & points

With Gift Cards and/or Loyalty enabled, the ticket picker gains a gift-card field, an account-credit toggle and a points field, under the promo-code input. Credits and a promo code are mutually exclusive — applying a promo stands the credits down with a message saying so (Stripe allows one discount per session, and keeping the same rule here means one rule to explain). Credits are measured against the selected tickets' total, ride into Stripe as a one-off coupon, and are only captured once the order completes; a full refund returns them. A 50c minimum stays card-payable, so a fully covered order still passes through Stripe rather than taking the free-ticket path. See gift-cards.md.

The member account page

Signed-in visitors get /members/event-tickets (the Event Tickets pill in the account nav, plus a card on the account dashboard). It lists every completed or refunded ticket order bought with the account's email — plus any whose bank payment is still clearing or bounced, labelled Payment processing / Payment failed — newest event first, and links each one to the tokenized order page — which stays the only place tickets, QR codes and wallet passes render. Abandoned pending orders are excluded. See memberships.md → The account area.

Waitlists

When a ticket type sells out, the widget offers Join waitlist (name + email, per occurrence for recurring events, idempotent per email). Whenever seats come back — a refund voided tickets, an abandoned hold expired, or the team raised the capacity — the tickets:notify-waitlist sweep (LazyCron, every 5 minutes) emails everyone waiting, exactly once each, with a link back to the event page. First come, first served: the waitlist notifies, it doesn't reserve.

Limitations

  • Recurring events share one capacity pool across occurrences. A capacity-50 ticket type on a weekly class means 50 tickets total, not 50 per week. The chosen occurrence is stamped on each order for the attendee list and calendar invite.
  • QR codes live on the linked ticket page, not inside the email — most email clients can't render them inline. The email's button (and the codes it lists) always lead back to the ticket page.
  • Refunds are full-order from the dashboard UI. Partial refunds issued directly in Stripe are still recorded correctly (the order shows the refunded amount) but don't void tickets until the full amount is refunded.
  • Event pages materialized before this addon shipped don't have the tickets section yet — add the Event Tickets row from the design library (Page Layouts → Detail) to the event detail page once via the page editor. Newer event-detail layouts include the section automatically.
  • Camera scanning needs HTTPS and a BarcodeDetector browser (Chrome/Edge/Android). Elsewhere, the per-ticket QR still opens the check-in page in any camera app, and the scan page's manual entry always works.
  • Apple Wallet passes require an Apple Developer account (a Pass Type ID certificate); Google Wallet requires a Wallet Console issuer account. Both are bring-your-own-credentials — without them the ticket page simply doesn't show wallet buttons.
  • Promo discounts collapse the Stripe line items into one discounted line (Stripe won't accept negative lines); the itemized breakdown stays on the order and the tickets.