footballmatrix

Football Matrix (footballmatrix.net) is a football results, squads and transfers website. It collects data from many providers, merges it into one clean "Matrix" dataset, and publishes only that merged data.

Code: /home/rob/code/footballmatrix ยท Last checked against commit 9f946c3 on 2026-10-14

Related: footballmatrix-redesign is an unreleased new look for the same site. Football Manager data used here is explained in football-manager.

What it does

Public pages: home, competitions, teams, players and fixtures (matches), plus transfers on those pages. Every item has a Matrix ID, used in the web address, such as /team/123. A password-protected /admin area lets the owner compare what each provider says about the same thing. It is read-only, and the older editing screens were deliberately deleted.

Which competitions are public is decided by one database column, matrix_competitions.coverage:

ValueMeaning
squads_transfersSquads and transfers only.
fixtures_basicPlus fixtures and scores.
fixtures_detailedPlus lineups and match detail.
empty (NULL)Not tracked, not published.

Data is collected from the 2020 season onwards.

How data flows

The order matters and is the most important rule in the project.

  1. Scrape "tf". tf means Transfermarkt for men's competitions and SoccerDonna (same company) for women's. This brings in competitions, fixtures, teams, squads, lineups and players.
  2. Build Matrix from tf (matrix:build). Only tf is allowed to create Matrix competitions, teams, fixtures and people.
  3. Scrape the other providers (SofaScore, FotMob, FBref, WhoScored, Football Manager data, and others).
  4. Match those to the tf-created rows, in the order: competitions, teams, fixtures, people.
  5. A match only fills in a link column on the provider's own row. A row that cannot be matched stays unlinked. It never creates a Matrix row. Builders that did this were removed in October 2026 because they made duplicates.
  6. Fill in Matrix fields by combining the linked sources.
  7. The public site reads only the matrix_* tables, never provider tables and never files in storage/.

Provider data is kept as the provider gave it, mistakes included. If one provider has two entries for the same player, the extra gets duplicate_of_id pointing to one main row, and only the main row carries the Matrix link. Two Matrix people that are really one are merged, keeping the older ID; the old address redirects. Never merge on name alone: a birth date plus club or match evidence is the minimum.

Provider code lives in app/Sources/<Provider> (BeSoccer, FBRef, FMRef, FootballManager, FootyStats, FotMob, Opta, PlaymakerStats, SoccerBase, SoccerDonna, SoccerWay, SofaScore, TransferMarket, UnderStat, WhoScored). Downloaded pages are cached on disk in the folder set by SCRAPES_DIR (/home/rob/scrapes/ here).

Running it

Production is served by nginx with PHP 8.4 from the repo's public folder (site footballmatrix.net, behind Cloudflare). Local steps:

Your values
cd /home/rob/code/footballmatrix
composer install
npm install
npm run build
php artisan serve --port={{PORT}}

Tests

cd /home/rob/code/footballmatrix
vendor/bin/phpunit

Tests use an in-memory SQLite database, which is more forgiving than the real MySQL. Always also check pages against the real database (see Deploying).

Configuration

Decisions about the data (current squads, current seasons, which team is in which league, merges) live in database tables, not config files. A small number of reviewed-decision files remain in config/matrix_*.php and are still honoured, but new decisions go in tables.

Scheduled jobs

The server crontab runs the Laravel scheduler every minute (php8.4 artisan schedule:run on this repo). The schedule is in routes/console.php:

Time (server)CommandWhat it does
05:55matrix:team-coverage --persistRefreshes which teams are eligible for collection.
06:00footballmatrix:sync-transfersScrapes transfer feeds.
06:00footballmatrix:sync-daily-fixturesScrapes recent and upcoming fixtures.
06:00footballmatrix:sync-competitionsScrapes squads and match details for covered competitions.
06:00matrix:daily-buildWaits for the three scrapes above, then updates the Matrix tables: new and changed fixtures, lineups, people, current seasons, team leagues, squads and links.

The build report is written to storage/app/private/daily-scrapes/matrix-build/.

Commands

Your values

Run from /home/rob/code/footballmatrix. Commands that change data preview by default and need --apply (or --persist for coverage) to write.

Refresh one league now

php artisan footballmatrix:sync-competitions --competition={{COMP_ID}}
php artisan footballmatrix:sync-competitions --dry-run

Also accepts --nation=. --dry-run lists the steps without scraping. The other two sync commands also take --dry-run.

Build Matrix from tf

php artisan matrix:build --competition={{COMP_ID}} --season={{SEASON}}
php artisan matrix:build --competition={{COMP_ID}} --season={{SEASON}} --apply
php artisan matrix:daily-build --dry-run

Other options: --nation=, --since=2021 (earliest season), --missing (only create missing rows), and --steps= to run a subset such as people,fixtures.

Change what is published

php artisan matrix:coverage --set={{COMP_ID}}:{{COVERAGE}}
php artisan matrix:coverage --set={{COMP_ID}}:{{COVERAGE}} --persist --reason="{{REASON}}"

The first line previews. The second applies it and keeps a journal entry.

php artisan matrix:link {{TABLE}} {{SOURCE_ID}} {{MATRIX_ID}} --reason="{{REASON}}"
php artisan matrix:link {{TABLE}} {{SOURCE_ID}} {{MATRIX_ID}} --reason="{{REASON}}" --apply
php artisan matrix:unlink {{TABLE}} {{SOURCE_ID}} --expect={{MATRIX_ID}} --reason="{{REASON}}" --apply
php artisan matrix:link-history {{TABLE}} {{SOURCE_ID}}

Unlinking records a "not the same" decision so matchers cannot relink the pair. Add --allow-relink if the link was only premature.

Scrape or process one provider feed

php artisan scrape {{SOURCE}} {{CLASS}} --filter=...
php artisan process {{SOURCE}} {{CLASS}} --from=...
php artisan scrape:failed {{SOURCE}}

Every download must go through a scraper class in app/Sources/*/Scrapers and through a proxy. Never fetch a provider any other way. A new feed gets a new scraper class; never point an old one at a new address, because its cache would be overwritten.

Other tools

Deploying

There is no build-and-release step for PHP. nginx serves the repo folder directly, and PHP checks file times on every request, so changed PHP is live the moment it is saved. Data changes are also live at once. That means you must test before saving files, not after.

Your values
cd /home/rob/code/footballmatrix
git pull origin {{BRANCH}}
composer install --no-dev --optimize-autoloader
npm run build
php artisan migrate --force

Checks after any change:

The nginx configuration is kept in deploy/nginx and installed with:

sudo bash /home/rob/code/footballmatrix/deploy/nginx/install.sh

It also sets up scrapes.footballmatrix.net, a password-protected view of the downloaded files. Cloudflare must be set to "Full (strict)" SSL, otherwise visitors get a redirect loop.

How it works

Gotchas

Changelog