Run a complete secret-ballot system with just the Engine + Sender — no proprietary GUI. You
operate a self-hosted instance through its artisan CLI commands (php artisan evote:*). The HTTP
API exists for the graphical app to consume; for headless self-hosting the CLI is the intended
interface.
| Service | Role |
|---|---|
| Engine (this repo) | Elections, ballots, voting codes, vote collection, results. Voters hit it directly. |
| Sender | Voter lists, ballot invites, verification, delivery tracking (AWS SES/SNS). |
| MySQL 8+ | Separate engine and sender schemas. |
| Queue worker | php artisan queue:work in the Sender (database queue — no Redis). |
| SMTP / AWS SES | Outbound mail for invites and results. |
Engine and Sender are deliberately separate apps with separate databases: the Sender holds the voter↔code mapping, the Engine holds the (encrypted) votes. Keeping them apart is the structural basis for the security levels.
Per app (engine, then sender):
cp .env.example .env
composer install --no-dev --optimize-autoloader
php artisan key:generate
php artisan migrate --force
php artisan evote:cacheIn the Sender, run a queue worker so mail sends: php artisan queue:work --tries=3.
Set a strong shared API token, matching DB_*, and (Sender) AWS_* / SES_MAX_SEND_RATE /
AWS_SNS_TOPIC_ARNS. Serve over HTTPS.
The commands below are run inside each app (
cd web_engine/cd web_sender, ordocker compose exec engine …/… sender …). Every command supports--helpfor its exact options. IDs printed by amake/listcommand feed the next step.
# Create an election (set the security level here: 1, 2 or 3)
php artisan evote:make:election --title="Board Election 2026" --level=1
# Create a ballot in it (use the election ID from the previous step)
php artisan evote:make:ballot --election=<EID> --title="Chair" --description="Elect the board chair"
# Add a question (component). Run with --help for the exact --options syntax / types.
php artisan evote:make:ballot:component --ballot=<BID> --title="Who should chair?" \
--type=fptp --options="Alice,Bob,Charlie"
# Generate single-use voting codes (one per voter)
php artisan evote:make:ballot:codes --ballot=<BID> --quantity=100
# Export the codes for the Sender (or for home_sender at Level 2/3)
php artisan evote:export:codes --ballot=<BID> --file=codes.jsonInspect anytime: evote:list:election, evote:show:election --election=<EID>,
evote:list:ballot --election=<EID>, evote:show:ballot --ballot=<BID>.
# Create a voter list (owner is your organization's UUID)
php artisan evote:make:voterlist --title="Members" --owner=<OWNER>
# Add voters — one at a time, or bulk from CSV
php artisan evote:add:voter --voterlist=<LID> --title="Alice" --email="alice@org.tld"
php artisan evote:add:voter --voterlist=<LID> --csv=voters.csv
# Send invites: feed the exported codes + a template; %%CODE%% is substituted per voter
php artisan evote:send:invites --voterlist=<LID> --codes=codes.json \
--template=invite.html --subject="Your ballot" \
--url="https://engine.example.org/election/<EID>/ballot/<BID>?code=%%CODE%%"# Open voting
php artisan evote:activate:ballot --ballot=<BID> # (engine)Voters click the link in their email, fill out the ballot at
https://engine.example.org/election/<EID>/ballot/<BID>?code=<code>, and submit. The engine encrypts
each submission onto that code's vote row.
# Close voting, then read the result
php artisan evote:deactivate:ballot --ballot=<BID> # (engine)
php artisan evote:result:ballot --ballot=<BID> # (engine)
# Email results to voters (export the result list first; see below for the CSV)
php artisan evote:send:results --voterlist=<LID> --csv=results.csv \
--subject="Results: Chair" --template=results.html \
--result-link="https://engine.example.org/election/<EID>/ballot/<BID>/result" # (sender)The results email includes the full anonymized ballot list so every voter can verify their vote and
the tally with their code — the integrity guarantee described in SECURITY_MODEL.md.
Check delivery with evote:stats:batch --batch=<UUID> (sender).
php artisan evote:make:verification --voterlist=<LID> --template=verify.html \
--subject="Confirm participation" --redirect-url="https://…" # (sender)
php artisan evote:send:verification --verification=<VID> # (sender)The --level you pass to evote:make:election is the model's core control (see
SECURITY_MODEL.md):
- Level 1 — distribute invites with the Sender's
evote:send:invitesas above. - Level 2 / 3 — the electoral commission distributes codes itself from its own machine using the
standalone open-source
home_sender(and, at Level 3, runs the metadata-stripping proxy). Export codes withevote:export:codesand hand them to that workflow instead ofevote:send:invites, so the platform never learns the code↔voter mapping.
- Backups:
mysqldumpboth schemas regularly (votes are encrypted with each app'sAPP_KEY— back the key up separately, and note its holder can decrypt; the levels, not the encryption, are what remove operator trust). - Mail health: watch the Sender's
failed_jobsand the SNS bounce/complaint flow. - 502s after big deploys are usually OPcache — restart PHP-FPM and
php artisan optimize:clear. - Security checklist: HTTPS everywhere · strong
API_TOKEN_LIST· protectAPP_KEY· setAWS_SNS_TOPIC_ARNS· keep the distribution and collection systems separated per the chosen level.