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
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\*.
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.
-
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 theversioncolumn (optimistic lock). -
ConfirmMatchResultaction — 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).
-
BracketResolverwired 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: aScenarioOverlaylayers 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.testlogin deep-clones the template into a private copy scoped to that session's token (CloneTournamentremaps every internal id, incl. thewinner:tie refs), so concurrent visitors never collide and the base template stays read-only for everyone.POST /demo/resetre-clones it; sandboxes expire after 24h and a scheduleddemo:prune-sandboxessweeps 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 chainedwinner:refs) + transactional Actions. A richTournamentDetailResource(stages → groups → matches withversion) feeds the front end. - Tournament editing:
PATCH /tournaments/{id}(rename) andPATCH /tournaments/{id}/teams/{team}(rename / flag, partial-safe — untouched fields survive) — owner-gated Actions.TournamentDetailResourcecarriescan_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 PHPGroupTableand 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 publicGET /api/tournaments/{id}/streamthat pushes a tiny frame on every committed result. Spectators refetch the authoritative snapshot — no polling, no business rules on the wire. ATournamentUpdatedevent fires after commit (the seam for a real broker); needsPHP_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.
Interactive OpenAPI docs (auto-generated from the code via Scramble, rendered with Scalar) are public and live at:
- Docs UI: https://gauntlet-api.marianacastro.dev/docs/api — try-it console included
- OpenAPI 3.1 spec: https://gauntlet-api.marianacastro.dev/docs/api.json
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 |
Before Composer, the engines already run — the smoke runners depend on nothing:
php scripts/smoke.php # group standings
php scripts/smoke-bracket.php # knockoutAfter installing dependencies, the Pest suite and the style gate:
composer install
./vendor/bin/pest
./vendor/bin/pint --test # verify, no writes; drop --test to fixBoth 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.
A single command populates the entire "Copa Atlas 2026" (decided groups + knockout in progress):
php artisan migrate:fresh --seedTest 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).
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).
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!