Offline-First Check-In: A Laravel API That Survives Venue Wi-Fi
TL;DR
- A gate check-in app can't depend on live Wi-Fi: scans must work offline and sync later.
- Four endpoints do it: manifest download, idempotent batch push, delta pull, online search.
- Client-generated UUIDs + a unique index make retries safe. Duplicates are a success status, not an error.
The problem
Physical event, staff scanning tickets at the door, venue Wi-Fi exactly as reliable as you'd expect. If your API sits in the hot path of every scan, the queue at the gate grows at the speed of the worst signal bar in the building.
So the design flips the roles: the device owns check-in, the server owns convergence. Like a cashier who keeps a paper ledger when the till goes down - record now, reconcile later.
The API surface
| Endpoint | Purpose |
|---|---|
GET /staff/events/{uuid}/manifest |
paginated ticket snapshot, downloaded before gates open |
POST /staff/events/{uuid}/check-ins/batch |
push queued scans; safe to retry |
GET /staff/events/{uuid}/check-ins?since=<cursor> |
pull what other devices did |
GET /staff/events/{uuid}/participants?q= |
online fallback search (lost ticket, typo) |
The sync loop
Device Server
|--- GET manifest (before event) -------> |
| scan offline, queue locally |
|--- POST batch [{client_uuid, ts}] ----> | dedupe on client_uuid
|<-- 200 {applied | duplicate per item} -| |
|--- GET check-ins?since=cursor --------> | scans from other devices
|<-- delta + next cursor ----------------| |
Idempotency is the whole trick
Every scan gets a UUID generated on the device at scan time. The server puts a unique index on it and inserts-or-ignores:
public function batchCheckIn(BatchCheckInRequest $request, string $uuid): JsonResponse
{
$results = collect($request->validated('check_ins'))
->map(function (array $scan) {
$checkIn = CheckIn::firstOrCreate(
['client_uuid' => $scan['client_uuid']],
[
'ticket_id' => /* resolved from scan */,
'checked_in_at' => $scan['scanned_at'],
],
// ...
);
return [
'client_uuid' => $scan['client_uuid'],
'status' => $checkIn->wasRecentlyCreated ? 'applied' : 'duplicate',
];
});
return response()->json(['results' => $results]);
}
The important design decision: a duplicate is not an error. A device that lost connection mid-upload will retry the whole batch - that's normal operation, not an exception. Return 200, mark the item duplicate, move on. First check-in wins.
Two timestamps matter: scanned_at from the device is the business truth ("when did this person walk in"), while the server's own time drives ordering. Device clocks lie; never build your sync cursor on them.
Delta pull
Other devices are checking people in too. GET /check-ins?since=<cursor> returns everything the device hasn't seen; it merges into local state and advances the cursor. The cursor comes from server-side time, so it's monotonic no matter how wrong any device's clock is.
Test the retry, not the happy path
The happy path will work. The retry is where offline-first designs quietly break:
it('applies the same batch twice without double check-ins', function () {
$payload = [
'check_ins' => [[
'client_uuid' => $uuid = Str::uuid()->toString(),
'code' => $this->ticket->code,
'scanned_at' => now()->toIso8601String(),
]]
];
postJson($this->endpoint, $payload)
->assertOk()
->assertJsonPath('results.0.status', 'applied');
postJson($this->endpoint, $payload)
->assertOk()
->assertJsonPath('results.0.status', 'duplicate');
expect(CheckIn::where('client_uuid', $uuid)->count())->toBe(1);
});
Takeaway
Offline-first changes the server's job from gatekeeper of every action to reconciler of state. And you get there with two boring properties - stable client-generated IDs and a monotonic server-side cursor - not with clever conflict resolution.
Comments
No comments yet. Start the discussion.