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:

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.

Your values
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:

  1. Puts the site into maintenance mode (visitors are told to retry after 60 seconds).
  2. Pulls the latest main branch (fast-forward only, so it stops if the server has local changes).
  3. Installs PHP packages without developer tools.
  4. Clears and rebuilds the Laravel caches.
  5. Runs database migrations (with --force).
  6. Makes sure the storage link exists.
  7. Restarts queue workers and reloads PHP-FPM (this step uses sudo systemctl reload php8.4-fpm, so the user needs permission to do that).
  8. 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):

Your values
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.

Your values

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

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:

Your values
* * * * * 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 oftenCommandWhat it is for
Every minutefantasy:transition-gameweeksLock the gameweek once its deadline passes and open the next
Every minutecalculate:stale-mini-league-standingsRefresh mini-league tables
Every minuteimport:teamsCurrently a stub that does nothing useful
Every 5 minutesimport:matrix-fixturesCopy new fixtures and match stats from footballmatrix
Every 30 minutescalculate:scoring-gameweeksUpdate points, standings and ranks of live gameweeks
Every 30 minutescalculate:transfer-gameweeksOwnership, transfer numbers and form for the open gameweek
Every 30 minutescalculate:price-changesPrice change signals
Every 30 minutescalculate:prediction-standingsPrediction game tables
Hourlyfantasy:finalise-gameweeksClose 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:

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:

  1. Scheduled: exists but not open yet.
  2. Open (the transfer window): managers can edit squads. This is the only time a squad can change.
  3. Locked (live): the deadline has passed and points grow as matches are imported.
  4. 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:

The full list is in docs/api/openapi.yaml, or run php artisan route:list --path=api.

What other people may see

Chips and squad rules

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:

Gotchas

Changelog