Skip to content

Configuration reference

All configuration lives in config/config.php (copy of config/config.example.php, which is the authoritative template and always up to date with the code — check it directly if this document and the template ever disagree). The file returns a plain PHP array; there is no environment-variable layer for the main app config (the standalone CLI scripts under bin/ are the exception — see the end of this document).

Core

KeyTypeDefaultNotes
env'prod'|'dev''prod''dev' shows error details, drops the HSTS Secure flag, disables template caching. Never use 'dev' on a public install.
app_urlstring'https://feedback.example.com'Public base URL, no trailing slash.
app_keystring'' (must be set)Signs session cookies and magic links. Generate: php -r "echo bin2hex(random_bytes(32));".
identity_server_keystring'' (must be set)HMAC key for email pseudonymization (ADR 0002) — deliberately separate from app_key for independent rotation. Losing this key makes every existing identity unrecoverable.
magic_link_ttlint (seconds)900 (15 min)Validity window of a sign-in link.
admin_emailsstring[][]Whoever signs in with one of these addresses becomes is_admin on first login.
session_lifetimeint (seconds)2592000 (30 days)Session cookie/token absolute lifetime (exp claim, never extended).
session_inactivity_windowint (seconds)1209600 (14 days)Rolling inactivity timeout (lact claim) — a session unused for longer is rejected even if session_lifetime hasn’t elapsed. Refreshed automatically on active use.
session_cookie_domainstring''Empty = host-only cookie (correct for self-host, where the install is the only origin). Cloud sets this explicitly to bind the cookie across all tenant paths on one shared origin. Secure/HttpOnly/SameSite are hardcoded, not configurable.

Database

'db' => [
    'host' => 'localhost', 'port' => 3306, 'name' => 'votepit',
    'user' => '', 'pass' => '', 'charset' => 'utf8mb4',
],

MySQL/MariaDB, accessed exclusively through prepared statements (Doctrine DBAL) — no string-concatenated SQL anywhere in the codebase.

SMTP

'smtp' => [
    'host' => '', 'port' => 587, 'user' => '', 'pass' => '',
    'encryption' => 'tls', // 'tls' | 'ssl'
    'from_email' => 'noreply@example.com', 'from_name' => 'Votepit',
    // Optional DKIM signing (RFC 6376) — both empty (default) disables it,
    // mail sends unsigned exactly as before. When set, every outgoing mail
    // is signed with d=<from_email's domain> (required for DMARC alignment)
    // and s=<dkim_selector>. Generate a keypair once, publish the public
    // half as a DNS TXT record at "<dkim_selector>._domainkey.<domain>",
    // then put only the private key here (PEM, gitignored like the rest of
    // config.php).
    'dkim_private_key' => '', 'dkim_selector' => '',
],

Used exclusively for magic-link/invite/notification mail. Verify a working setup with php bin/send-test-mail.php you@example.com before going live (see operations.md) — there is no in-app fallback if mail delivery is broken, sign-in will simply fail.

OAuth login (Google/GitHub)

'oauth_providers' => [
    'google' => ['client_id' => '', 'client_secret' => ''],
    'github' => ['client_id' => '', 'client_secret' => ''],
],

Additive to magic-link/password/TOTP login, never a replacement. Both empty/missing (the default) means no provider button is shown anywhere and GET /login/oauth/{provider}/start fail-secure 404s — a self-host installation with no configuration here simply never exposes OAuth login at all. Only the google/github keys are recognized; a provider is only considered configured once both client_id and client_secret are non-empty.

To enable a provider:

  1. GoogleGoogle Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth client ID (type: Web application).
  2. GitHubgithub.com/settings/developers → OAuth Apps → New OAuth App.
  3. In either console, register the authorization callback / redirect URI exactly as:
    {app_url}/login/oauth/google/callback
    {app_url}/login/oauth/github/callback
    ({app_url} is this installation’s app_url config value, e.g. https://boards.example.com — an exact mismatch is rejected by the provider before Votepit ever sees the request.)
  4. Copy the generated client ID and client secret into oauth_providers.<provider> above. Like every other secret in this file, config.php is gitignored — never commit real credentials.

No further scope configuration is needed — Votepit only ever requests each provider’s minimal “identify me” scope (OpenID/email-equivalent), never write access to the linked account. A signed-in user can see which providers are linked to their account and unlink them from /profile at any time; unlinking never risks a lockout, since magic-link sign-in (POST /login) always works for every user regardless of OAuth linkage.

Machine translation (Google Cloud Translation)

'translation' => [
    'provider' => '',   // '' (default) | 'google'
    'google' => [
        'project_id'          => '',
        'service_account_key' => '',   // absolute path to the service account's JSON key file
        'region'              => 'eu',
    ],
],

Lets a signed-in voter view an idea’s/comment’s content auto-translated into their current UI language, with an unobtrusive “show original” toggle — never a manual “Translate” button. Off by default and fail-secure: provider empty/missing, or a 'google' config missing project_id/service_account_key, disables the feature entirely (NullTranslationProvider) — the translate endpoints then respond 503 translation_unavailable rather than ever throwing, and every idea/comment stays fully readable in its original language regardless.

The only provider currently implemented calls Google Cloud Translation Advanced (v3) against its EU multi-regional endpoint (translate-eu.googleapis.com) — not the global endpoint, not Basic (v2), for EU data-residency. This means the idea/comment text being translated is sent to Google as a third-party processor; if you enable this, you are the data controller for your own installation and take on that same subprocessor relationship with Google that this decision implies — evaluate it against your own privacy policy/DPA obligations before turning it on.

To set it up (self-host, own GCP project — this is unrelated to and independent from any other Votepit installation):

  1. Create a GCP project (or reuse an existing one you control) and enable the Cloud Translation API on it.
  2. Create a dedicated service account and grant it exactly roles/cloudtranslate.user — the least-privilege role for calling Translate; never roles/owner/roles/editor.
  3. Generate a JSON key for that service account and store it outside the repo/webroot, like every other secret referenced from config.php.
  4. Set translation.provider = 'google' and fill in project_id (the GCP project ID), service_account_key (absolute path to the key file from step 3), and region (leave as 'eu' unless you have a specific reason to change it — this is what selects the EU-resident endpoint).
PROJECT_ID="your-project-id"
gcloud services enable translate.googleapis.com --project="$PROJECT_ID"
gcloud iam service-accounts create votepit-translate \
  --project="$PROJECT_ID" --display-name="Votepit Cloud Translation"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:votepit-translate@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/cloudtranslate.user"
gcloud iam service-accounts keys create votepit-translate-key.json \
  --iam-account="votepit-translate@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="$PROJECT_ID"

Google Cloud Translation is a paid API past its free monthly quota — set a budget alert (and ideally a hard billing cap/auto-stop) on your GCP project before enabling this in production; Votepit itself enforces no spending limit on your behalf, only the request- level rate limits below. See the translation:idea/translation:comment buckets under Rate limits to bound worst-case request volume.

Anonymous voting

'turnstile' => [
    'site_key'   => '',   // Cloudflare Turnstile site key
    'secret_key' => '',   // Cloudflare Turnstile secret key
],

Board owners can allow voting without an account on a per-board basis (off by default — each board opts in individually in its board settings, boards.anonymous_voting_enabled). An anonymous voter is deduplicated per idea via a purpose-bound, HttpOnly cookie (no device fingerprinting); voting still goes through the same per-IP rate limits as authenticated voting (idea:vote, see Rate limits), and every new anonymous voter cookie is challenged once via Cloudflare Turnstile before its first vote counts. Anonymous voters may optionally leave an email address to be notified when an idea’s status changes — stored encrypted (never in plaintext or as an unsalted hash) and used for nothing else.

Set up Turnstile before enabling anonymous voting on a public board. Exactly like Machine translation above, this is fail-secure-by-absence in the permissive direction: without site_key/secret_key configured, turnstileConfigured() is false and the challenge step is skipped entirely rather than blocking voting — anonymous voting still works, but without its bot-abuse gate. Get a free site/secret key pair from the Cloudflare Turnstile dashboard and set both values before opting any board into anonymous voting in production.

Routing mode (tenancy)

KeyValuesDefaultEffect
routing_mode'self-host'|'cloud''self-host''self-host': exactly one account, board paths are /{board}/.... 'cloud': multiple accounts, paths become /{account}/{board}/....

Self-hosters leave this at 'self-host'. Setting 'cloud' requires the built SPA to have account-prefixed client routes — the boot-time check in public/index.php (Votepit\SpaCapabilities) fails loudly with HTTP 500 rather than silently 404ing every account-scoped page if the SPA build doesn’t support it yet.

Network trust

KeyTypeDefaultEffect
trust_cloudflare_ipboolfalsetrue trusts the CF-Connecting-IP header for rate limiting/logging instead of REMOTE_ADDR. Only enable this together with an origin lock that rejects direct traffic not coming from Cloudflare’s published IP ranges — otherwise any client can forge the header and bypass IP-based rate limits.

Error monitoring

KeyTypeDefaultEffect
sentry_dsnstring''Empty (default, recommended for self-host): NullErrorReporter, no outbound telemetry. Set to a real DSN to activate Votepit\Monitoring\SentryErrorReporter — uncaught exceptions are additionally reported to Sentry on top of the existing error_log logging.

Analytics

KeyTypeDefaultEffect
matomo_urlstring''Empty (default): no analytics tracker is loaded, /api/bootstrap reports matomo_url: ''. Set together with matomo_site_id to load a cookieless Matomo tracker (disableCookies, no consent banner needed) in the SPA — see core/app/src/lib/analytics.ts. This is your own optional analytics, separate from the Community Edition product telemetry below.
matomo_site_idstring''The Matomo site ID paired with matomo_url.

Community Edition product-improvement telemetry is a separate, non-config-driven mechanism — see Votepit\Telemetry\CommunityTelemetry. It sends aggregate, anonymous, cookieless usage signals (no PII, IP-anonymized) to Votepit’s own Matomo instance to help prioritize development, and is on by default with an easy opt-out toggle under /admin/account (accounts.telemetry_opted_in). It is inert automatically in routing_mode: cloud.

Rate limits

'rate_limits' => [
    'global:ip'       => ['limit' => 300, 'window' => 60],
    'magiclink:email' => ['limit' => 3,   'window' => 3600],
    'magiclink:ip'    => ['limit' => 5,   'window' => 3600],
    'idea:submit'     => ['limit' => 5,   'window' => 3600],
    'idea:vote'       => ['limit' => 60,  'window' => 60],
    'comment:user'    => ['limit' => 10,  'window' => 3600],
    'comment:react'   => ['limit' => 60,  'window' => 60],
    'dupsearch:user'  => ['limit' => 30,  'window' => 60],
    'smtp:test'       => ['limit' => 5,   'window' => 300],
    'invite:send'     => ['limit' => 20,  'window' => 3600],
    'apitoken:read'   => ['limit' => 120, 'window' => 60],
    'apitoken:write'  => ['limit' => 20,  'window' => 3600],
    'translation:idea'    => ['limit' => 60, 'window' => 600],
    'translation:comment' => ['limit' => 60, 'window' => 600],
],

Fixed-window limiter, buckets keyed <action>:<identity> and stored in the rate_limits MySQL table. limit => 0 disables an action entirely (fails every request for that bucket). The config key is the same string the code looks up ($config->rateLimit('idea:submit') etc.) — don’t rename keys without updating the corresponding call site. apitoken:read/apitoken:write are also the buckets used by the MCP endpoint (a token’s MCP and REST usage share the same budget — see mcp-server.md).

To reset a bucket during manual verification (never for production traffic):

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

Extensions (optional)

'extensions' => [],

The Community Edition is complete on its own: every account is on an unlimited plan, no plan or payment logic exists in this package. A hosted service built on top of it can register additional code here — the classes listed must implement Votepit\Extension\AppExtension and be autoloadable when config.php is evaluated (the extension package requires its own autoloader from config.php). Self-host installs leave the list empty; there is nothing to configure and nothing changes at runtime.

Everything an extension can influence is enumerated by that interface — anything not listed is deliberately out of reach:

HookWhat it may do
register()Add its own routes (global or under the account prefix), with core’s AuthZ/rate-limit middleware. ExtensionContext also hands it the shared LoginSessionIssuer, the one sanctioned way to sign a visitor in.
planPolicy()Replace the unlimited Community plan policy (at most one extension).
csrfExemptions()Exempt a header-authenticated machine endpoint (e.g. a payment webhook) from CSRF.
accountDeletionPrecondition()Run a check before an owner-requested account deletion is scheduled.
bootstrapFeatures()Add features flags to GET /api/bootstrap (e.g. legal footer links).
responseHeaders()Add static headers to every response (e.g. X-Robots-Tag). Core’s own security headers are reserved and cannot be overridden.
routeMiddleware()Attach middleware to a short, fixed list of core-owned routes (Votepit\Http\CoreRoute: robots.txt, the mail-sending login/password-reset/invite/SMTP-test endpoints, and the rate-limited idea/vote/comment/duplicate-search endpoints). The middleware sits outermost on the route, so it can refuse the request before core runs or observe core’s answer (e.g. a 429). Unknown names, or names whose route does not exist in the current routing_mode, abort the boot.

The SPA has the matching seam (core/app/src/extensions/types.ts, resolved through the @votepit/app-extensions alias): extra pages, admin-nav entries, i18n strings, and two fixed mount points (slots.appBanner above every page, slots.loginFooter below the sign-in forms).

Extensions are expected to be pure PHP on top of core’s own vendor/ tree (production servers run only core’s Composer autoloader). That is why composer.json carries a few libraries core itself does not call — currently dompdf/dompdf, used by extensions that render PDF documents — rather than every extension shipping its own dependency tree.

CLI scripts using environment variables

Unlike the main app, bin/send-test-mail.php reads SMTP settings from environment variables instead of config.php (so it can be run against a different mail configuration without touching the live config):

SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_ENCRYPTION, SMTP_FROM_EMAIL, SMTP_FROM_NAME

Example values in config/smtp-test.env.example. See operations.md for usage and the other bin/ scripts, several of which take CLI flags instead (--dry-run, --out=, --target-name=, …).