Skip to content

Operations

Migrations

Versioned, forward-only migration runner (Votepit\Migrations\MigrationRunner), invoked via bin/migrate.php:

php bin/migrate.php            # shows pending migrations, asks for a backup confirmation, applies
php bin/migrate.php --dry-run  # shows what would run, applies nothing

File naming: NNNN_description.sql or NNNN_description.php, a 4-digit ascending prefix (sorted lexically, so numbering order = apply order). .sql files hold a handful of related DDL statements separated by ;\n — no string literals containing an embedded ;\n. .php files end with return new SomeMigrationClass();; the class implements the Migration interface (optionally ConfigAwareMigration if it needs config/secrets, e.g. to backfill a value derived from identity_server_key).

0000_baseline.sql and db/schema.sql are never edited again once created — every future schema change is a new file in migrations/. Always take a fresh backup immediately before running a migration against a database that matters (using your own mysqldump or hosting provider’s tooling — this package does not ship backup/restore tooling, see below).

Backup/restore

This package does not ship any backup, restore, or per-tenant-extraction tooling. Back up and restore the configured MySQL database using standard tooling (mysqldump, your hosting provider’s snapshot/backup feature, or a managed-database backup product) — whatever fits your own operational setup. There is no Votepit-specific mechanism to test or automate this.

Removing expired accounts and self-deleted users

php bin/cleanup-expired-accounts.php

Deletes accounts past their 30-day deletion grace period (accounts.deletion_scheduled_at), cascading via ON DELETE CASCADE foreign keys. Not scheduled automatically — must be added to cron manually if you run a multi-account deployment. Extensions that keep their own account-referencing tables clean up through AppExtension::accountDeletionPrecondition() before the delete runs.

The same run then erases users whose own 48h self-deletion grace period has passed (users.deletion_scheduled_at, requested under Profile → “Delete my account”). Their ideas and comments stay, attributed to a shared “Deleted user” placeholder; their votes stay (scores unchanged) but are no longer linked to anyone; everything else (profile, avatar, notification email, sign-in methods, memberships, reactions, notifications) is removed. Account owners and operators are skipped until they no longer hold that role.

Logging & error monitoring

  • Audit log (Votepit\Logging\AuditLogger) — pseudonymized security-relevant events, written to logs/audit.log by default (outside the web root), falling back to PHP’s error_log if that path isn’t writable. Emails in log context are masked (foo@bar.tld → f**@b**.tld#<12-char SHA-256 suffix> — readable and correlatable without being reversible). Secrets (app_key, passwords, plaintext tokens) must never reach the log context — this is enforced by convention in the codebase, not by a filter.
  • Error monitoring — ErrorReporter interface with two implementations selected by config.php’s sentry_dsn: NullErrorReporter (default, no-op — the correct choice for most self-host installs) and SentryErrorReporter (active once a DSN is set; uncaught exceptions are reported to Sentry in addition to the existing error_log output).

Health & readiness probes

Two unauthenticated endpoints for uptime monitors and load balancers. Both answer a minimal JSON body and never reveal versions, paths or error text.

  • GET /health — liveness only: 200 {"ok":true} whenever PHP and the request pipeline are up. Never touches the database.

  • GET /ready — readiness: 200 {"ok":true} only when all of these hold, otherwise 503 {"ok":false}:

    1. the database answers SELECT 1;
    2. no migration is pending — core’s migrations/ (schema_migrations) and every extension stream (e.g. Votepit Cloud’s billing_schema_migrations);
    3. the cron manager (bin/run-due-crons.php) recorded its heartbeat within readiness_cron_max_age_minutes (default 120 — see configuration.md).

    The probe is read-only, sent with Cache-Control: no-store, and subject to the global per-IP rate limit. Which check failed is never in the response; it is written to the audit log as readiness.not_ready with failed = database, migrations and/or cron. Diagnose with php bin/migrate.php --dry-run and php bin/check-cron-heartbeats.php.

curl -s -o /dev/null -w '%{http_code}\n' https://votes.example.com/ready   # 200 = ready

Point a load balancer’s readiness check (or a “deploy done” gate) at /ready and the liveness/restart check at /health — restarting PHP does not fix a pending migration or a missing cron entry.

Rate limiting

See configuration.md for the full bucket table. Buckets live in the rate_limits MySQL table, keyed <bucket>:<identity>; resetting one during manual testing:

DELETE FROM rate_limits WHERE bucket LIKE '%magiclink%';

Never do this to route around limits on anything resembling real traffic.

Mail testing

SMTP_HOST=... SMTP_PORT=... SMTP_USER=... SMTP_PASS=... SMTP_ENCRYPTION=tls \
SMTP_FROM_EMAIL=... SMTP_FROM_NAME=... php bin/send-test-mail.php you@example.com

Sends through the exact same mailer code path as magic links (SymfonyMailerAdapter) — a clean run means the production SMTP config will work for sign-in, not just “SMTP is reachable”. See config/smtp-test.env.example.