wfs-backend
The server side of World Fantasy Soccer (WFS), a fantasy football game in the style of Fantasy Premier League that covers many countries and leagues. It stores users, squads and points, copies real match data in from footballmatrix, and moves every gameweek through its life on a timer.
Code: /home/rob/code/wfs-backend ยท Last checked against commit 6fc19d8 on 2026-10-14
WFS is its own game and has nothing to do with Football Manager. Related pages: wfs-frontend (the website that talks to this), wfs-beta-backend (an older copy), footballmatrix (where the real match data comes from).
What it does
This repo has no web pages of its own. It is a JSON API: the website (wfs-frontend) and the mobile app ask it for data and send it changes. Everything is under /api. The game works like this:
- A real season is a real league year (for example a league and its 2025/26 year). A fantasy season is the game people play on top of it. Each fantasy season has its own rules, budget, chips and list of gameweeks.
- A user makes one entry (their team) per fantasy season. They pick a squad of 15 players: 11 starters and 4 substitutes, with a captain and vice-captain, limited to a set number of players from any one club.
- A gameweek is one round of matches. Before its deadline, managers can make transfers and change their picks. After the deadline the squad is frozen and points are added up as real matches are played.
- Chips are one-use power-ups: wildcard, free hit, bench boost and captain choice (triple captain or both captains).
- Mini-leagues let friends compare entries, with an optional manual points adjustment.
- Predictions are a side game: users predict match results and get their own standings.
- Price changes move player values up or down based on how many people buy and sell them.
The in-repo names are confusing in places, so note: a "team" in the squad sense means the user's players, while "real-world team" means the club. The repo's own glossary lives in agent/ inside the frontend repo; the backend's rules are in agent/notes/rules.md.
Running it
On this server the code lives in /home/rob/code/wfs-backend. It needs PHP 8.4 or newer and a MySQL database. The automated tests use a throwaway in-memory database, so they are safe to run. Never run migrate or other database commands against a database with real data unless you mean to.
cd {{APP_DIR}}
composer install
cp .env.example .env
php artisan key:generate
Then fill in the database settings in .env (see Configuration). Run the tests with:
cd {{APP_DIR}}
php artisan test
Tests take about a minute. For a quick local server, php artisan serve works; the developer's own machine uses Laravel Herd instead.
Deploying
The repo contains a ready-made script, deploy.sh, that does a whole deployment. The long first-time setup guide for a fresh Ubuntu server (nginx, PHP-FPM, MySQL, certificates, queue worker, cron) is in deploy.md. Follow that once; after that, deploying is one command.
cd {{APP_DIR}}
./deploy.sh
What the script does, in order:
- Puts the site into maintenance mode (visitors are told to retry after 60 seconds).
- Pulls the latest
mainbranch (fast-forward only, so it stops if the server has local changes). - Installs PHP packages without developer tools.
- Clears and rebuilds the Laravel caches.
- Runs database migrations (with
--force). - Makes sure the
storagelink exists. - Restarts queue workers and reloads PHP-FPM (this step uses
sudo systemctl reload php8.4-fpm, so the user needs permission to do that). - Brings the site back up and prints the deployed commit. If anything fails, it still brings the site back up and says so.
The script always pulls main; it does not take a branch argument.
Two private packages (robjbrain/laravel-defaults and robjbrain/laravel-resources) are installed from GitHub, so the deploy user needs SSH access to those repos.
Quick checks after a deploy (replace the domain with the real one):
curl -I https://{{DOMAIN}}/up
php artisan about
The first should answer 200. In php artisan about, expect the environment to read production, debug off, and caches shown as cached.
Commands
These are run from the repo folder with php artisan. Most are also run automatically (see Scheduled jobs) and you only run them by hand to fix or rebuild something. Where an option says "id", it means the database id of that row, not its name.
Gameweek life cycle
# Lock the gameweek whose deadline has passed and open the next one
php artisan fantasy:transition-gameweeks --season={{SEASON_ID}}
# Close out finished gameweeks (works out who played, auto-subs, captain bonus)
php artisan fantasy:finalise-gameweeks --season={{SEASON_ID}}
# Redo one gameweek and every later due one in the season, even if already finished
php artisan fantasy:finalise-gameweeks --gameweek={{GAMEWEEK_ID}}
# Redo every gameweek in the season
php artisan fantasy:finalise-gameweeks --season={{SEASON_ID}} --all
# Recompute gameweek numbers and open / lock / finalise times from the fixtures
# (--dryrun shows before and after without saving; --active limits to live seasons)
php artisan fantasy:reset-gameweek-times --season={{SEASON_ID}} --dryrun
Leave out --season to cover every active season.
Points, ranks and prices
# Refresh live points, entry totals, standings and ranks
php artisan calculate:scoring-gameweeks --season={{SEASON_ID}}
# Same, but for every locked gameweek, not just the live ones
php artisan calculate:scoring-gameweeks --season={{SEASON_ID}} --all
# Refresh ownership, transfer counts and form for the gameweek now open for transfers
php artisan calculate:transfer-gameweeks --season={{SEASON_ID}}
# Redo per-player rows whose match stats were re-imported
php artisan calculate:gameweek-players --season={{SEASON_ID}}
# Recalculate player ownership, transfer numbers and form for locked gameweeks
php artisan calculate:gameweek-player-form --season={{SEASON_ID}} --all
# Daily price changes (--stats-only updates the numbers without moving prices)
php artisan calculate:price-changes --season={{SEASON_ID}}
# Mini-league tables that went stale
php artisan calculate:stale-mini-league-standings
# Prediction game standings (--all for every season)
php artisan calculate:prediction-standings --season={{REAL_SEASON_ID}}
Note the id types: calculate:prediction-standings takes a real season id, the others take a fantasy season id.
Importing real match data
# Pull fixtures, players and match stats for one real season
php artisan import:matrix-fixtures --season={{REAL_SEASON_ID}}
# Only gameweeks that are locked and not yet finalised (what the scheduler uses)
php artisan import:matrix-fixtures --live
# Only rows changed in footballmatrix since a time
php artisan import:matrix-fixtures --season={{REAL_SEASON_ID}} --updated-since="2026-01-01 00:00:00"
# Import real seasons and their teams (--dry-run previews)
php artisan import:matrix-seasons --dry-run
# Import players: source is one of sofascore_season, season, fantasy_season, sofascore_team, team
php artisan import:matrix-players season {{REAL_SEASON_ID}}
# Re-read every local player from the Transfermarkt and SofaScore tables
php artisan refresh:matrix-players --chunk=200
Other date filters for fixtures are --kickoff-since, --kickoff-before and --updated-before; --id= targets specific source fixture ids; --all does every real season that has a SofaScore id.
Clean-up
# See what would be deleted (this is the safe default)
php artisan fantasy:cleanup-never-activated-gameweek-players --season={{SEASON_ID}} --dry-run
# Actually delete
php artisan fantasy:cleanup-never-activated-gameweek-players --season={{SEASON_ID}} --force
Also present but not meant for routine use: import:teams (an unfinished stub that is nonetheless scheduled), old-import:matrix-fixtures (the previous importer), and some one-off repair commands under app/Console/Commands/tmp.
Helper scripts in the repo root
fix_gameweeks.shis just a pasted list offantasy:finalise-gameweeks --gameweek=IDlines from a past clean-up. Do not run it as is; the ids are historic.export_fixture.shdumps chosen fixtures (with incidents and player stats) from the footballmatrix database as SQL you can replay elsewhere:./export_fixture.sh 15186710,15186711. It connects over SSH to a hard-coded host. It also has a database password written into the script; setDB_PASSyourself when running it and treat that file as sensitive.
Scheduled jobs
Laravel's scheduler runs everything below, but only if the server's cron runs it every minute. Without this, gameweeks never open, lock or score. The cron line (it assumes the code is in the folder below) is:
* * * * * cd {{APP_DIR}} && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
Check what is installed with crontab -l, and see the schedule with php artisan schedule:list. A queue worker must also be running (php artisan queue:work) because some recalculations are queued as jobs. deploy.md has a ready-made systemd service for it.
| How often | Command | What it is for |
|---|---|---|
| Every minute | fantasy:transition-gameweeks | Lock the gameweek once its deadline passes and open the next |
| Every minute | calculate:stale-mini-league-standings | Refresh mini-league tables |
| Every minute | import:teams | Currently a stub that does nothing useful |
| Every 5 minutes | import:matrix-fixtures | Copy new fixtures and match stats from footballmatrix |
| Every 30 minutes | calculate:scoring-gameweeks | Update points, standings and ranks of live gameweeks |
| Every 30 minutes | calculate:transfer-gameweeks | Ownership, transfer numbers and form for the open gameweek |
| Every 30 minutes | calculate:price-changes | Price change signals |
| Every 30 minutes | calculate:prediction-standings | Prediction game tables |
| Hourly | fantasy:finalise-gameweeks | Close out gameweeks whose finalise time has passed |
Each is set not to overlap with itself, so a slow run does not pile up.
Configuration
Settings live in .env (copied from .env.example). Never commit it. The ones that matter for this app:
APP_ENV,APP_DEBUG,APP_URL: set toproduction, off, and the public address on a live server.DB_CONNECTION,DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME,DB_PASSWORD: the main game database (MySQL).DB_MATRIX_DATABASE: name of the second database, the footballmatrix one the importers read from. It defaults tofootballmatrix_2025and is defined as thematrixconnection inconfig/database.php. It is read with the same host, user and password as the main database, so both databases must be on the same MySQL server.SESSION_DOMAIN,SANCTUM_STATEFUL_DOMAINS,SESSION_SECURE_COOKIE: must all agree with the website's address, otherwise logins from the website fail with a 419 error.QUEUE_CONNECTION,CACHE_STORE,SESSION_DRIVER: alldatabasein the guide, so Redis is not needed.
After changing .env on a server, run php artisan optimize:clear then php artisan optimize, or the old values keep being used.
How it works
The gameweek cycle
Each gameweek moves through four states:
- Scheduled: exists but not open yet.
- Open (the transfer window): managers can edit squads. This is the only time a squad can change.
- Locked (live): the deadline has passed and points grow as matches are imported.
- Finished: auto-substitutions and captain bonuses are applied and results are final.
Two stored times drive this: lock_at (deadline) and finalise_at (earliest the week may be closed out). When the deadline passes, fantasy:transition-gameweeks locks that week and opens the next in one step, copying each manager's squad forward as their starting point. Every 30 minutes while matches are on, calculate:scoring-gameweeks refreshes points. Once finalise_at passes, fantasy:finalise-gameweeks works out who actually played from imported minutes, runs auto-subs, applies captain multipliers and stamps the week as finished. Full detail with a worked example: agent/notes/gameweeks-cycle.md.
Where match data comes from
Real-world data (leagues, clubs, players, fixtures and per-player match stats) is not typed in. It is copied from the second database, footballmatrix, which already merges SofaScore, Transfermarkt and Soccerdonna. This is the only outside source. Imported rows keep the original ids in sofascore_id, tf_id and fm_id columns. The old matrix_id is ignored. There is also a read-only /api/matrix area for global moderators to inspect the source tables; it can never change data.
The API
There are roughly 180 routes. Public ones (viewing seasons, leaderboards, players, fixtures) need no login; changes need a login token (Laravel Sanctum). The groups are:
/api/fantasy/...: the game itself: entries, picks, drafts, transfers, gameweeks, chips, leaderboards, mini-leagues, player stats, permissions, imports for a fantasy season./api/competitions,/seasons,/fixtures,/players,/teams,/regions: the real-world data, with edit rights for moderators./api/predictions,/prediction-standings,/prediction-logs: the predictions side game./api/matrix: read-only inspection of the footballmatrix tables./api/import/...: moderator buttons that pull one competition, season, team, player or fixture from footballmatrix./api/tags,/table-views,/search,/users: helper features for the website.
The full list is in docs/api/openapi.yaml, or run php artisan route:list --path=api.
What other people may see
- Other managers' picks are hidden until that gameweek's deadline has passed. If you ask early you get an empty list, not an error.
- Other people's predictions are hidden until the match has kicked off.
- Global moderators can see everything.
Chips and squad rules
- A valid squad has exactly 15 picks: 11 starters and 4 substitutes, and no more than the allowed number from one club.
- A user has one entry per season. User accounts are never deleted; deleting marks them
deletedand blocks that email from logging in or registering again. - Each chip can be used once per entry per season. Wildcard and free hit change transfer rules but add no points. Bench boost makes all 15 players score. Captain choice must be paired with an option, triple captain (3x) or both captains (vice-captain also doubles).
- Free transfers carry over from week to week up to a per-gameweek maximum. A free hit week is skipped when carrying the squad forward.
The calculation rules are written up in agent/notes/calculations.md, and the full list of invariants in agent/notes/rules.md.
Where things are in the code
The code is split by job rather than by the usual Laravel layout. Look here first:
app/Console/Commands: the commands above.app/Provisioners: multi-step set-up and recalculation work (opening gameweeks, finalising).app/Calculatorsandapp/RankBuilders: the maths for points and ranks.app/Mutators: single-row writes.app/Orchestrators: workflows across several tables.app/MatrixSourcesandapp/MatrixImporters: reading from footballmatrix.agent/skills: the coding rules for each folder, written for AI agents but readable.agent/health/db-tables.mdanddb-fields.md: which code writes to each table and column. Keep these updated when you change a writer.
Gotchas
- No cron, no game. If gameweeks are not opening or locking, check
crontab -land the queue worker first. - Two kinds of season id. Fantasy season id and real season id are different numbers; commands and the matrix endpoint expect one or the other.
- Edited
.envnot taking effect. The config is cached. Runphp artisan optimize:clearthenphp artisan optimize. - Login problems (419) from the website. Check the domain settings listed under Configuration all match.
deploy.shpulls with fast-forward only. If someone edited files on the server it will stop; the site is brought back up after the failure.- Real data in dev. The developer database holds real data; do not run migrations or SQL on it casually. Schema changes always go in new migration files; never edit an applied one.
- Late imports. A match re-imported after its gameweek finished can change results; this is a known open issue (see
agent/issues/). - Plans in progress. A move from MySQL to Postgres is planned (
agent/plans/medium/plan-postgres.md) but not done. Some older notes inagent/are out of date; where they disagree with the code, trust the code.
Changelog
- Initial version.