Skip to content

How-to: Waitlist System

FT reference: FT287 (NENE2-FT/waitlistlog) — Waitlist system: UNIQUE(user_id) one-entry constraint, waiting→approved/declined state machine, isTerminal() guard, /waitlist/me registered before /{id} to prevent route capture, X-Admin-Key authentication, queue position tracking, 39 tests / 98 assertions PASS.

This guide shows how to build a waitlist system where users join a queue and administrators approve or decline entries.

Schema

sql
CREATE TABLE IF NOT EXISTS waitlist_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL UNIQUE,   -- one entry per user
    status     TEXT    NOT NULL DEFAULT 'waiting',  -- waiting | approved | declined
    note       TEXT,                               -- optional user-provided note (max 500 chars)
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

user_id UNIQUE enforces one entry per user at the DB level — no application-layer check needed for race conditions.

Endpoints

MethodPathAuthDescription
POST/waitlistX-User-IdJoin the waitlist
GET/waitlist/meX-User-IdGet own status + position
DELETE/waitlist/meX-User-IdLeave the waitlist
GET/waitlistX-Admin-KeyAdmin: list all entries
POST/waitlist/{id}/approveX-Admin-KeyAdmin: approve entry
POST/waitlist/{id}/declineX-Admin-KeyAdmin: decline entry

Route Registration Order

/waitlist/me must be registered before /waitlist/{id} to prevent the path parameter from capturing the literal string "me":

php
// CORRECT: static path before dynamic path
$this->router->get('/waitlist/me', $this->handleMe(...));
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));

// WRONG: {id} would capture "me"
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));
$this->router->get('/waitlist/me', $this->handleMe(...));  // never reached

Status Lifecycle

waiting ──────→ approved (terminal)
       └──────→ declined (terminal)

Once approved or declined, an entry cannot transition to another state. The isTerminal() method guards this:

php
enum WaitlistStatus: string
{
    case Waiting  = 'waiting';
    case Approved = 'approved';
    case Declined = 'declined';

    public function isTerminal(): bool
    {
        return $this !== self::Waiting;
    }
}

Joining with 409 on Duplicate

php
$entry = $this->repository->join($userId, $note);

if ($entry === null) {
    return $this->responseFactory->create(['error' => 'Already on the waitlist.'], 409);
}

The repository returns null when user_id already exists (caught from DatabaseConstraintException). The response is 409 Conflict.

Position Tracking

php
$position = $this->repository->positionOf($entry);

// positionOf() counts entries with status='waiting' and id <= $entry->id
// SELECT COUNT(*) FROM waitlist_entries WHERE status = 'waiting' AND id <= ?

Position is the 1-based rank in the waiting queue. Approved/declined entries don't count. This gives users a meaningful place in line.

Admin Transition with match

php
private function handleTransition(int $id, WaitlistStatus $newStatus): ResponseInterface
{
    $result = $this->repository->transition($id, $newStatus);

    return match ($result) {
        'ok'               => $this->responseFactory->create(['status' => $newStatus->value]),
        'not_found'        => $this->responseFactory->create(['error' => 'Entry not found.'], 404),
        'already_terminal' => $this->responseFactory->create(['error' => 'Entry is already approved or declined.'], 409),
        default            => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
    };
}

match is exhaustive — the default case catches any unexpected return values from the repository.

Leave (Only While Waiting)

php
return match ($result) {
    'removed'     => $this->responseFactory->create(['removed' => true], 200),
    'not_found'   => $this->responseFactory->create(['error' => 'Not on the waitlist.'], 404),
    'not_waiting' => $this->responseFactory->create(['error' => 'Cannot leave — status is no longer waiting.'], 409),
    default       => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
};

Once approved or declined, a user cannot leave — their decision is recorded. This prevents gaming the system (approve then leave to avoid tracking).

Admin Authentication

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false;  // fail-closed: no key configured → no admin access
    }
    return hash_equals($this->adminKey, $request->getHeaderLine('X-Admin-Key'));
}

hash_equals() prevents timing attacks. Empty admin key always returns false (fail-closed).

Note Validation

php
private const int MAX_NOTE_LEN = 500;

private function resolveNote(mixed $raw): ?string
{
    if (!is_string($raw) || trim($raw) === '') {
        return null;
    }
    return mb_strlen($raw) > self::MAX_NOTE_LEN ? mb_substr($raw, 0, self::MAX_NOTE_LEN) : $raw;
}

Notes are optional (null if absent/empty), max 500 characters, truncated (not rejected) if too long.


What NOT to do

Anti-patternRisk
No UNIQUE(user_id) constraintConcurrent joins create duplicate entries; race condition
Register /{id} before /me/waitlist/me becomes unreachable — matched by {id} capturing "me"
Allow transition from terminal stateApproved entry declined after access granted; state machine broken
Allow leave from terminal stateApproved user leaves; access grant becomes orphaned
Return position based on id ASC counting all entriesCounts approved/declined users; position number is misleading
Store admin key in DBKey rotation requires DB update; use env var instead
Use == instead of hash_equals() for admin keyTiming attack reveals key one character at a time
No admin fail-closedEmpty key in env allows unauthenticated admin access
Reject note if over limitUX: truncating is friendlier than rejecting for soft metadata like notes

Released under the MIT License.