Check-in at the door
Codes
Entering going issues each RSVP a check-in code: EV1- followed by 32 base32 characters — 160 random bits from Crypto for Laravel — stored encrypted with a SHA-256 hash index, looked up by hash and never decrypted in bulk. Leaving going revokes it. The code is hidden on the model and never appears in managers’ lists, domain events, logs or about.
use RoundlyConsulting\Qr\DataTransferObjects\QrOptions;
$rsvp->checkInCode(); // EV1-… — for the attendee's own ticket page
$rsvp->checkInQr(new QrOptions(size: 320, margin: 2)); // an inline SVG, never cached
Events::rsvps()->qr($rsvp); // the same image
Events::rsvps()->findByCode($scannerInput); // the RSVP behind a code, or a URL containing one
Events::rsvps()->by($organizer)->rotateCode($rsvp); // a new code; the old one stops workingThe QR is an inline SVG from QR for Laravel with Secret sensitivity, so it is never cached whatever the options say; the Svg is Htmlable, Responsable and Stringable (toDataUri() for an img tag). Its payload is events.check_in.url with {code} when set — a phone camera then opens your door app — else the bare code. Scanner input is trimmed and upper-cased and the first EV1-… code inside it is used, so a URL payload or a scanner’s suffix still reads. Never use the x-qr-code Blade component for check-in codes.
{{-- the attendee's ticket page --}}
{{ $rsvp->checkInQr() }}
<p>{{ $rsvp->checkInCode() }}</p>The JSON API serves the same image to its owner at GET /events/{event}/rsvp/qr.svg with Cache-Control: no-store.
At the door
$result = Events::for($event)->by($doorStaff)->checkIn($scannedCode, guests: 2); // scoped to this event
$result->firstCheckIn; // false on a repeat scan ("already in since 20:04")
$result->checkedInAt; // CarbonImmutable (UTC) of the first check-in
$result->guests; // the party's guests checked in so far
$result->method; // CheckInMethod::Code (CheckInMethod::Manual by hand)
$result->attendee; // the attendee model
$result->rsvp; // the RSVP (checked_in_by records the staff member)
$result = Events::checkIn($scannedCode, by: $gateStaff); // unscoped: a festival gate scanning several events- Only managers check in (the checkIn ability). The unscoped scan finds the code’s event and checks the policy there; a non-manager is answered like an unknown code, so it is no oracle.
- Always name the staff member with by() in a door controller. A handle without by() is trusted PHP and skips the policy — copied into a controller, it would let any signed-in user check anyone in.
- Scans are idempotent under concurrency: exactly one firstCheckIn per code (a compare-and-set update). Late guests raise checked_in_guests on a repeat scan.
- The write also requires the RSVP to still be going and to still hold the scanned code: an attendee who declines, is removed or has their code rotated while the scan is in flight is refused. Once checked in, the attendee cannot leave, so a scan and a decline racing on one RSVP have exactly one winner.
- The attendee is resolved before anything is written: a soft-deleted account keeps its RSVP and seat, so its ticket still checks in; an attendee row that is gone refuses the scan and writes nothing.
- The window opens events.check_in.opens_before (PT3H) before the start and closes events.check_in.closes_after (PT2H) after the end; ignoreWindow: true skips it.
Refusals
| Exception | code | HTTP | When |
|---|---|---|---|
InvalidCheckInCodeException | invalid_code | 404 | An unknown or revoked code — and, unscoped, a non-manager. |
CheckInRefusedException | check_in_refused | 409 | A known code the door turns away; reason() is wrong_event (which one is never named), not_going, event_not_active (draft, postponed, cancelled or deleted — a completed event keeps its door open until the window closes) or outside_window. |
RsvpNotFoundException | rsvp_not_found | 404 | The attendee’s row is gone — nothing is written. |
use RoundlyConsulting\Events\Exceptions\CheckInRefusedException;
use RoundlyConsulting\Events\Exceptions\InvalidCheckInCodeException;
try {
$result = Events::for($event)->by($request->user())->checkIn((string) $request->input('code'));
} catch (InvalidCheckInCodeException) {
// an unknown or revoked code (404 invalid_code)
} catch (CheckInRefusedException $e) {
$e->reason(); // wrong_event, not_going, event_not_active or outside_window
}By hand
Events::for($event)->by($organizer)->attendees()->checkIn($user, guests: 1); // ignores the window
Events::for($event)->by($organizer)->attendees()->undoCheckIn($user);
Events::for($event)->by($organizer)->attendees()->checkedIn(); // by check-in timeAttendeeCheckedIn(CheckInResult) and CheckInUndone fire, and a checked-in RSVP stays going — it cannot decline afterwards. Both lock the RSVP row in their own transaction, so a decline or a removal cannot land between the read and the write, and two undos announce one CheckInUndone.
A leaked code
If a ticket screenshot leaks, Events::rsvps()->rotateCode($rsvp) issues a new code at once; the old one stops working, even in the middle of a scan.
Your own code format
For a signed, offline-verifiable format, bind Contracts\CheckInCodes — issue(Rsvp $rsvp): string, extract(string $input): ?string and hash(string $code): string — in a service provider. The default Support\RandomCheckInCodes is bound with bindIf, so yours wins:
// AppServiceProvider::register()
$this->app->bind(\RoundlyConsulting\Events\Contracts\CheckInCodes::class, App\Events\SignedCheckInCodes::class);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.