* feat: add GDPR data export (download data) button Wires up the previously disabled "Download data" button on the settings page. Downloads a zip with a profile.xlsx (account + owned seasons), and per owned season a folder with each quiz's xlsx (questions, results, eliminations tabs) and a candidates.xlsx (candidates + season info tabs). Soft-deleted rows are included and flagged so the export reflects everything the app still holds about the user. * feat: include question bank in GDPR data export Adds a question-bank.xlsx per season folder with Questions (bank questions, answers, reusable/complete flags, labels, and which quizzes they've been used in) and Labels tabs, since BankQuestion/BankAnswer content was previously missing from the export. * fix: hard-delete quiz/audit-log data when deleting an account QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their removal through Season -> Quiz/Candidate only set deletedAt instead of physically deleting the row. Since Candidate and Answer are hard-deleted via orphanRemoval, this broke their foreign keys and rolled back the entire account deletion whenever a candidate had actually participated in a quiz. Bulk DQL deletes now purge these rows before the cascade runs. Also purge BankQuestion audit-log rows (ext_log_entries), which store the editor's username/email but aren't foreign-keyed to the entity they log, so they were never cleaned up and would otherwise keep a deleted user's email around indefinitely. * style: make the download data button primary (blue) * feat: add raw answers crosstab to quiz export Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one column per question, with the given answer text in each cell — the raw data behind the aggregated Results tab. * i18n: translate new settings page string to Dutch * refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by symfony/string's AsciiSlugger) and uses it everywhere user-controlled text (season/quiz names, account email) ends up in a zip entry path or a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9 survive; everything else, including unicode and path-traversal sequences, is folded or stripped) rather than a denylist of "unsafe" characters, and it's an officially maintained Symfony component already present as a transitive dependency. Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint that built its Content-Disposition filename directly from an unsanitized quiz name — the same class of risk the data export already guarded against. Naming note: sanitized names are now slugs (spaces become dashes, unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend". * fix: check ZipArchive open/close results and clean up temp files on failure Addresses CodeRabbit findings on the GDPR export: - ZipArchive::open() and close() can both return false without throwing; neither was checked, so a failure silently produced an empty or corrupt zip, and the temp zip path was never cleaned up on a mid-build exception. - writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save() threw before the caller could track it for cleanup. * fix: drop public link identifier from the candidates export sheet The nameHash is a public quiz-access token, not something a data export should hand out — remove it from candidates.xlsx. * feat: add Quiz info tab covering dropouts, finalization, and disabled questions An entity-by-entity audit of the export vs. delete flows found the delete flow fully covered, but three Quiz/Question fields missing from the export: dropouts, finalizedAt, and Question.enabled. Adds a new "Quiz info" tab (first sheet) to each quiz's xlsx with this data, without touching the shared fillQuestionsSheet() used by the existing single-quiz template export/import feature. Deliberately left out per user decision: the BankQuestion audit log (would leak other owners' emails, consistent with hiding co-owner identities elsewhere in this export) and a few low-value timestamp fields already covered by existing Started/time-taken columns. * feat: require a confirmed email before exporting data Antispam measure: both the full data export (SettingsController::downloadData) and the single-quiz export (BackofficeController::exportQuiz) now redirect with a flash warning instead of exporting when the account's email isn't verified yet. Adds a matching hint on the settings page next to the download button.
Tijd voor de test
PHP/Symfony application for WIDM-style quiz management. Built with FrankenPHP, PostgreSQL, and Docker.
Disclaimer: This is an unofficial, non-commercial, open-source fan project. It is not affiliated with, endorsed by, or associated with Wie is de Mol? (produced by IDTV, broadcast by AVROTROS/NPO) or De Mol (produced by Woestijnvis, broadcast by Play/De Vijver Media). Wie is de Mol? and De Mol are trademarks of their respective rights holders. No copyright infringement is intended.
Requirements
- Docker
- Just (
brew install just)
Local development
just up # Start PHP + PostgreSQL containers
just migrate # Run pending database migrations
just fixtures # Load dev fixtures (truncates first)
The app is available at https://localhost (self-signed cert — run
just trust-cert on macOS to trust it).
Useful commands
just shell # Shell inside the running PHP container
just shell-run # Shell in a fresh one-off container
just stop # Stop containers (keep volumes)
just down # Stop and remove containers
just clean # Nuclear: remove containers + volumes + generated files
just exec <cmd> # Run any command inside the PHP container
Environment
Copy .env and override locally via .env.local (not committed):
| Variable | Description |
|---|---|
APP_SECRET |
Symfony app secret |
DATABASE_URL |
PostgreSQL DSN (auto-set in Docker) |
SENTRY_DSN |
Sentry error tracking |
DEFAULT_URI |
Base URL for CLI-generated links |
Testing
just test # Full PHPUnit suite
just test tests/Path/To/TestFile.php # Single file
just test --coverage-html var/coverage # HTML coverage report
just reload-tests # Drop/recreate test DB + migrate + test fixtures
Tests use a separate database configured via .env.test. The DAMA
Doctrine bundle wraps each test in a transaction that is rolled back after.
just reload-tests loads the --group=test fixtures; just fixtures
loads the dev group and is unrelated to the test database.
Code quality
All checks run in CI and must pass before merging.
just fix-cs # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
just phpstan # PHPStan static analysis (level 8)
just rector # Apply Rector modernizations
just rector --dry-run # Preview Rector changes without applying
Database
just migrate # Run pending migrations
just fixtures # Load dev fixtures
bin/console make:migration # Generate a new migration (inside container)
Migrations live in migrations/ (namespace DoctrineMigrations). Test
fixtures are in src/DataFixtures/ loaded with --group=test.
Translations
just translations # Extract/update nl translation strings into translations/
Contributing
- Create a branch from
main— use a prefix likefeat/,fix/, ordocs/. - Open a pull request; CI must pass before merging.
- Install the pre-commit hook (see below) to catch issues before pushing.
Pre-commit hook
A pre-commit hook lives in .githooks/pre-commit. Install it once after cloning:
just install-hooks
On every commit it runs automatically, only on staged files:
| Staged file type | Tools run |
|---|---|
.php |
Rector → PHP-CS-Fixer (auto-fix + re-stage), then PHPStan (blocks on errors) |
.twig |
Twig-CS-Fixer (auto-fix + re-stage) |
| Other (docs, config, …) | Nothing — commit proceeds immediately |
If the PHP container is not running, the hook falls back to
docker compose run --rm so checks still execute. PHPUnit is not
run in the hook; CI covers that.
Deployment
Docker images are published to ghcr.io/marijndoeve/tijdvoordetest
for each tagged release.
First-time setup
- Copy
compose.yamlandcompose.prod.yamlto your server. - Create a
.env.prod.localfile with the required variables (see below). - Start the stack — migrations run automatically on container start:
IMAGE_TAG=latest docker compose -f compose.yaml -f compose.prod.yaml up -d
Updating to a new version
IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml pull
IMAGE_TAG=<tag> docker compose -f compose.yaml -f compose.prod.yaml up -d
Required environment variables
| Variable | Description |
|---|---|
IMAGE_TAG |
Image tag to run (e.g. 1.2.3 or latest) |
APP_SECRET |
Random secret string for Symfony |
CADDY_MERCURE_JWT_SECRET |
JWT secret for the Mercure hub |
POSTGRES_PASSWORD |
PostgreSQL password |
MAILER_DSN |
Mailer transport DSN |
MAILER_SENDER |
From address for emails |
SENTRY_DSN |
Sentry project DSN (optional) |
The compose.prod.yaml configures Traefik labels for TLS termination at
tijdvoordetest.nl. Adjust the traefik labels in that file if you're
hosting on a different domain or using a different reverse proxy.