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 tologs/audit.logby default (outside the web root), falling back to PHP’serror_logif 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 —
ErrorReporterinterface with two implementations selected byconfig.php’ssentry_dsn:NullErrorReporter(default, no-op — the correct choice for most self-host installs) andSentryErrorReporter(active once a DSN is set; uncaught exceptions are reported to Sentry in addition to the existingerror_logoutput).
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, otherwise503 {"ok":false}:- the database answers
SELECT 1; - no migration is pending — core’s
migrations/(schema_migrations) and every extension stream (e.g. Votepit Cloud’sbilling_schema_migrations); - the cron manager (
bin/run-due-crons.php) recorded its heartbeat withinreadiness_cron_max_age_minutes(default 120 — seeconfiguration.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 asreadiness.not_readywithfailed=database,migrationsand/orcron. Diagnose withphp bin/migrate.php --dry-runandphp bin/check-cron-heartbeats.php. - the database answers
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.