Getting started
Scheduled jobs and backups
These jobs run on a schedule from cron (not Symfony Scheduler: it only fires while a Messenger worker runs,
and the default deployment runs none). Each console command is idempotent, safe to run again, and takes
--dry-run to show what it would do without changing anything.
| Command | Schedule | What happens if it never runs |
|---|---|---|
app:account:process-deletions | daily | Accounts whose deletion was requested more than 30 days ago are never anonymised. |
app:auth:purge | daily | Used sign-in codes and expired verified devices pile up in the database. |
app:matchday:run | every 15 minutes | Players aren't asked whether they can play, nobody is reminded, captains never hear who hasn't answered, results the other side never confirms stay out of the tables, nobody is reminded to arrange their box matches, box rounds never end, so nobody moves box, ladder challenges never expire, nobody is reminded of a fee they owe, a court held for a payment that never came stays held until someone else books it, a venue's held earnings wait for its next payment or an admin's check after it connects, members aren't reminded before their memberships renew or end, memberships paid by hand never end, and data sources don't run on their schedules. |
bin/backup-db | nightly | There is no copy of the database to go back to. |
Crontab
30 2 * * * cd /path/to/app && DB_USER=sports DB_NAME=sports MYSQL_PWD=... bin/backup-db >> var/log/cron.log 2>&1
*/15 * * * * cd /path/to/app && php bin/console app:matchday:run >> var/log/cron.log 2>&1
30 3 * * * cd /path/to/app && php bin/console app:account:process-deletions >> var/log/cron.log 2>&1
50 3 * * * cd /path/to/app && php bin/console app:auth:purge >> var/log/cron.log 2>&1
10 4 * * * cd /path/to/app && php bin/console app:privacy:retention >> var/log/cron.log 2>&1
With Docker, the cron service runs the console jobs from docker/crontab. A new scheduled job goes in
both places.
Backups
bin/backup-db writes a compressed dump to BACKUP_DIR (default /var/backups/sports), deletes dumps older
than BACKUP_KEEP_DAYS (default 14), and copies the new one to BACKUP_S3_URI when it's set (it needs the
aws command). It reads the password from MYSQL_PWD, so the password never shows in the process list.
Keep a copy off the server: a dump on the same disk is lost with the disk.
DB_HOST and DB_PORT default to 127.0.0.1 and 3306. With Docker, the database container has its own
mysqldump, so the host's cron can dump from inside it:
30 2 * * * cd /path/to/app && docker compose exec -T database sh -c 'exec mysqldump --single-transaction --routines --triggers --no-tablespaces -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' | gzip > /var/backups/sports/sports-$(date +\%Y\%m\%d).sql.gz
A managed database or a volume snapshot taken by the host works as well.
Restoring a backup
-
Put the platform in maintenance: stop the web server, the worker and cron, so nothing writes meanwhile.
-
Restore into a new, empty database first, and check it before touching the live one:
mysql -u sports -p -e "CREATE DATABASE sports_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" gunzip -c /var/backups/sports/sports-20261106-023000.sql.gz | mysql -u sports -p sports_restore mysql -u sports -p sports_restore -e "SELECT COUNT(*) FROM users" -
Point
DATABASE_URLin.env.localatsports_restore, or rename the databases, then clear the cache (php bin/console cache:clear) and start everything again. -
Sign in to the admin and check the latest records you expect, then delete the old database.
Restore with the same client family that made the dump. MariaDB's mysqldump starts a dump with a
"sandbox mode" line that MySQL's mysql client refuses; drop that line first with
gunzip -c dump.sql.gz | sed 1d | mysql ....
The restore was tried on 2026-11-06: a dump of the development database, restored into an empty database, had the same tables and the same row count in each.
Background queue (optional)
Emails and push notifications go through Messenger. The transport is MESSENGER_TRANSPORT_DSN, which
defaults to:
MESSENGER_TRANSPORT_DSN=sync://
sync:// handles each message in the request, so no worker is required. To move that work into the
background:
MESSENGER_TRANSPORT_DSN=doctrine://default
then run a worker (the transport creates its own messenger_messages table on first use):
php bin/console messenger:consume async --time-limit=3600
Supervise it, so it starts again after the time limit or a crash: the worker profile of compose.yaml
restarts it (restart: unless-stopped), and on a server without Docker a systemd unit does the same:
# /etc/systemd/system/sports-worker.service
[Unit]
Description=Sharkhex Sports queue worker
After=network.target mysql.service
[Service]
User=www-data
WorkingDirectory=/path/to/app
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemctl enable --now sports-worker starts it, and systemctl kill sports-worker shows it coming back.
The sign-in code email is always sent at once, whatever the transport: sign-in waits for the code, and is refused when it can't be sent.
Found a mistake? Tell us.