Skip to content

Repository files navigation


Gauntlet API
Gauntlet API

Tournament management API where state is a projection, not a datum.

PHP Laravel Pest SQLite PostgreSQL

Architecture • Current State • API • Running

The engineering value isn't in the screens — it's in keeping the state always coherent: standings, tiebreak criteria, and bracket advancement that recompute, within a transaction, on every result submitted. The source of truth is the match results; standings, goal difference, who advanced, and the champion are all derived by pure functions — editing a result means recomputing the projection, not syncing mutable state.

🔗 Live demo: gauntlet-api.marianacastro.dev/docs/api


Architecture

The rules core lives in app/Domain/Tournament, with no framework dependency whatsoever (zero Illuminate\, zero Eloquent). Controllers, Eloquent, Requests, and migrations stay Laravel; the Eloquent → DTO translation happens at the edge (the Actions layer).

app/Domain/Tournament/
├── Input/
│   ├── MatchResult.php   # DTO: result of a finished match
│   └── TeamRef.php       # DTO: team reference
├── Standings/
│   ├── Criterion.php     # enum of tiebreak criteria
│   ├── TiebreakRules.php # ordered chain — ::fifa(), ::of(...)
│   ├── Standing.php      # immutable value object (a table row)
│   └── GroupTable.php    # the pure standings engine
└── Bracket/
    ├── SlotSource.php      # where a side comes from (group seed | tie winner)
    ├── Tie.php             # topology of a knockout tie
    ├── TieResult.php       # DTO: score + penalties
    ├── MatchOutcome.php    # decides the winner (regular time and penalties)
    ├── ResolvedTie.php     # value object: resolved tie (what the UI consumes)
    └── BracketResolver.php # pure knockout engine (derives slots, winners, champion)

Dependency rule: Laravel → Domain, never the other way around. Nothing in app/Domain may import Illuminate\* or App\Models\*.

"Projection", precisely — a read-model, not event sourcing

To preempt the obvious question: this is a derived read-model, not event sourcing. There is no event store, no aggregate, no replay. Match results are the single source of truth, persisted as ordinary rows in matches; recording a result is a plain UPDATE under an optimistic lock (ConfirmMatchResult).

What is derived is everything downstream. Standings, tiebreaks, goal difference and bracket advancement hold zero stored state — they are recomputed by the pure GroupTable / BracketResolver engines on every read and, on every write, inside the same transaction that recorded the result. So "projection" here is the CQRS read-model sense (a computed view over the results table), not an event-sourced projection over a log.

The claim is narrow and deliberate: no denormalized standings can ever drift from the results, because there is nothing denormalized to drift. The cost that buys — recompute on every read — is affordable precisely because a tournament's state is small and bounded; event sourcing would be machinery this domain doesn't need.

Current state

  • GroupTable — pure standings engine, with configurable criteria and recursive head-to-head (mini-league among the tied teams).
  • BracketResolver — pure knockout engine: derives participants, decides winners (penalties included), elects the champion, and propagates "to be determined" through the rounds.
  • Migrations + Eloquent models (tournaments, teams, stages, groups, matches, ties), with indexes and the version column (optimistic lock).
  • ConfirmMatchResult action — stitches Eloquent ↔ Domain inside the transaction, with an optimistic lock that rejects concurrent edits (StaleResultException → HTTP 409).
  • REST API with Sanctum: token auth, public reads (standings and bracket), result submission protected by owner (Policy), validation (422), and version conflict (409).
  • BracketResolver wired to the database: knockout derived from the seeds (projection of the groups) + topology; the same result endpoint serves groups (→ standings) and knockout (→ bracket).
  • ProjectScenario — the "what if?" projection: a ScenarioOverlay layers hypothetical results over the real matches and the same pure engines recompute the whole tournament without writing anything; a hypothetical group result cascades into the bracket seeds. A public, unauthenticated read.
  • Demo seeder: the full "Copa Atlas 2026" (4 decided groups + knockout in progress) in a single command, with an organizer of known credentials — a rich, browsable API instantly.
  • Per-session demo sandbox: the shared demo@bracket.test login deep-clones the template into a private copy scoped to that session's token (CloneTournament remaps every internal id, incl. the winner: tie refs), so concurrent visitors never collide and the base template stays read-only for everyone. POST /demo/reset re-clones it; sandboxes expire after 24h and a scheduled demo:prune-sandboxes sweeps them hourly.
  • Tournament assembly (CRUD): create a tournament, add teams, set up the group stage, and generate the single round-robin and the bracket — via new pure engines (RoundRobinScheduler, KnockoutSeeder, with the A1×B2 crossover and the chained winner: refs) + transactional Actions. A rich TournamentDetailResource (stages → groups → matches with version) feeds the front end.
  • Tournament editing: PATCH /tournaments/{id} (rename) and PATCH /tournaments/{id}/teams/{team} (rename / flag, partial-safe — untouched fields survive) — owner-gated Actions. TournamentDetailResource carries can_manage (the authoritative policy result for the token-bearing caller) so the UI shows edit controls only to the owner and keeps the demo template read-only. Renames are safe: standings and the bracket are keyed by id, not name.
  • Cross-engine conformance: a shared tests/Vectors/standings.json (byte-identical to the front end's copy) that both the PHP GroupTable and the TypeScript standings engine must reproduce — so the two implementations can't silently drift on tiebreaks (e.g. head-to-head).
  • Live spectator stream (SSE): a per-tournament revision (bumped in the same save transaction) backs a public GET /api/tournaments/{id}/stream that pushes a tiny frame on every committed result. Spectators refetch the authoritative snapshot — no polling, no business rules on the wire. A TournamentUpdated event fires after commit (the seam for a real broker); needs PHP_CLI_SERVER_WORKERS.
  • Tests: Domain scenarios + property test + feature tests (real database + end-to-end API, incl. knockout advancement, penalties, the seeder, the full assembly, the what-if scenario, the demo sandbox clone/isolation/prune, and the live revision/stream). Pest suite is green.

API

Interactive OpenAPI docs (auto-generated from the code via Scramble, rendered with Scalar) are public and live at:

Locally they're at /docs/api and /docs/api.json, or export a static copy with php artisan scramble:export.

Method Route Auth What
POST /api/register · /api/login — issues a Sanctum token (the demo login also provisions a per-session sandbox)
GET /api/groups/{group}/standings — group standings (projection of the matches)
GET /api/stages/{stage}/bracket — resolved bracket + champion
POST /api/tournaments/{tournament}/scenario — projects hypothetical results (standings + bracket) without persisting — the "what if?" engine
PUT /api/matches/{fixture}/result owner submits/edits a result → group returns standings, knockout returns bracket; 409 on version conflict
GET /api/tournaments/{tournament} — full view (stages → groups → matches with version); adds can_manage for the token-bearing caller
GET /api/tournaments/{tournament}/stream — Server-Sent Events: pushes a small {revision} frame on every committed result, so public spectator views refetch live
GET · POST /api/tournaments owner lists mine · creates one (draft)
PATCH /api/tournaments/{tournament} owner renames the tournament
DELETE /api/tournaments/{tournament} owner removes (cascade)
POST /api/tournaments/{tournament}/teams owner adds teams in bulk
PATCH /api/tournaments/{tournament}/teams/{team} owner renames a team / updates its flag (partial-safe)
POST /api/tournaments/{tournament}/group-stage owner sets up groups + generates the single round-robin
POST /api/tournaments/{tournament}/knockout owner generates the bracket from the groups (422 if not complete)
POST /api/demo/reset demo drops this session's demo sandbox and clones a fresh one from the template
GET /api/user · POST /api/logout token session

Running

Before Composer, the engines already run — the smoke runners depend on nothing:

php scripts/smoke.php          # group standings
php scripts/smoke-bracket.php  # knockout

After installing dependencies, the Pest suite and the style gate:

composer install
./vendor/bin/pest
./vendor/bin/pint --test   # verify, no writes; drop --test to fix

Both run in CI. Pest is wrapped by laravel/pao, whose TUI can swallow failure detail — PAO_DISABLE=1 ./vendor/bin/pest gives plain pass/fail output, and CI sets it for exactly that reason.

Demo

A single command populates the entire "Copa Atlas 2026" (decided groups + knockout in progress):

php artisan migrate:fresh --seed

Test organizer — for the protected endpoints: demo@bracket.test / password. Then just browse: GET /api/stages/{id}/bracket, GET /api/groups/{id}/standings.

Logging in as the demo organizer provisions a per-session sandbox — a token-scoped clone of the template — so edits never touch the shared data and concurrent visitors stay isolated. POST /api/demo/reset restores a clean copy. Sandboxes expire after 24h (DEMO_SANDBOX_TTL_HOURS); run php artisan demo:prune-sandboxes to sweep them, or let the scheduler do it hourly (needs php artisan schedule:run on a cron in production).

Live spectator stream (SSE)

GET /api/tournaments/{id}/stream holds a text/event-stream connection and pushes a small { tournament_id, revision, type, ts } frame whenever the tournament's revision advances — i.e. a result committed. It exposes only already-public data, so no auth. Correctness comes from the client refetching the snapshot on each frame (and on reconnect); the revision is just the "ignore if not newer" guard.

Concurrency is required. php artisan serve is single-process; one held stream would otherwise block every other request (including the organizer's save). Both composer dev and the Railway start command run php artisan serve --no-reload with PHP_CLI_SERVER_WORKERS=8 — Laravel only honours the workers with --no-reload (the file-watcher can't fork). Trade-off in dev: the PHP server no longer hot-restarts on code changes, so restart it by hand (Vite still hot-reloads the front end). Timing knobs live in config/sse.php (SSE_MAX_SECONDS, SSE_POLL_MS, SSE_RETRY_MS, SSE_HEARTBEAT_MS). Behind a proxy/CDN, make sure it doesn't buffer the response (the endpoint sends X-Accel-Buffering: no + Cache-Control: no-cache).

Notes

  • docs/mocks/ holds the high-fidelity mock of the interface (design reference; the UI itself will live in the front end, a separate project).
  • Simplifications documented in the engine: the exact order of the FIFA rulebook is tunable by just reordering TiebreakRules::fifa(); the drawing-of-lots criterion (random) is replaced by a deterministic input order, better for reproducibility and testing.


© 2025–2026 Mariana Castro · Live demo

⭐ If you like this project, give it a star on GitHub!

About

Tournament management API built around derived state: standings, tiebreaks and knockout progression are computed from match results, not stored.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages