Online Voting System for Student Council Elections using PHP and MySQL with Source Code

Language

The Online Voting System is a web application built with PHP and MySQL for student council, SSG and school organization elections. Voters sign in with their Student ID, fill out an official ballot and get a printable vote receipt. The election officer manages positions, candidates and voters, opens and closes the polls, watches turnout live, and prints the final results with winners and ties marked automatically.

It is a classic capstone and thesis topic, and this version is written for modern PHP 8 with PDO, prepared statements, hashed passwords and CSRF protection. It also handles the parts that simple voting systems usually get wrong: positions where you vote for more than one candidate, abstaining, double submission, and ties. The database comes with sample positions, candidates and ten voters, so you can try a full election in a few minutes. The complete source code is free to download below.

Online Voting System Basic Information

  • Language used: PHP 8.0+ (tested with PHP 8.3)
  • Database used: MySQL or MariaDB (tested with MariaDB 11.8) through PDO
  • Front end: plain HTML, CSS and a few lines of JavaScript, no framework or CDN
  • Coding Tool used: VS Code, Sublime Text or any text editor
  • Type: Web Application

About the Project

Many schools still run student elections on paper: printed ballots, a ballot box, and a long night of manual counting where one mistake means recounting everything. An online voting system lets students vote from a phone or a computer lab, makes sure each student votes only once, and produces the tally the moment the polls close.

The system has two sides. Voters open the main page, log in with their Student ID and password, and see the ballot grouped by position. Single-seat positions such as President use radio buttons; multi-seat positions such as Councilor ("vote for 3") use checkboxes, and a small script stops the voter from ticking more than allowed. After submitting, the voter sees a receipt of their choices and cannot vote again. The election officer logs in at /admin/ to prepare the ballot, register voters, open or close voting, and print the results.

Features

Voter side

  • Login with Student ID and password (hashed with password_hash())
  • Official ballot grouped by position, with partylist, course and year, and a short platform for each candidate
  • Radio buttons for "vote for 1" positions, checkboxes for multi-seat positions
  • Abstain on any position by leaving it blank
  • Confirmation before submitting, then a printable vote receipt
  • One vote per voter, enforced on the server and in the database
  • Clear "Voting is closed" message when the polls are not open

Admin (election officer) side

  • Dashboard with registered voters, number who voted, turnout percentage and progress bar, and the latest voters
  • Open and close voting with one click, and edit the election title
  • Positions: add, edit, set how many candidates to vote for, and set the ballot order
  • Candidates: add, edit and delete (a candidate who already has votes cannot be deleted)
  • Voters: register, search, filter by voted / not yet voted, change passwords, delete voters who have not voted
  • Results page with vote counts, percentage bars, winners and ties detected automatically, and a print layout
  • Reset Election button (type RESET to confirm) to clear test votes before the real election

Screenshots

The main screenshot above shows the results page. More screenshots of the voter login, ballot, receipt and admin pages are in the gallery. All of them were taken from the running project with the sample data.

Code Highlights

Saving a ballot safely. The ballot is validated first: every candidate must belong to the position it was submitted for, and no position can have more choices than its "vote for" limit. Then the votes are saved inside a transaction. SELECT ... FOR UPDATE locks the voter's row, so if the same student submits from two tabs at the same moment, the second request waits, sees that voted_at is already set, and is turned away:

  1.     if (!$errors) {
  2.         $pdo = db();
  3.         $pdo->beginTransaction();
  4.         try {
  5.             // Lock the voter row so two tabs cannot vote at the same time.
  6.             $stmt = $pdo->prepare('SELECT voted_at FROM voters WHERE id = ? FOR UPDATE');
  7.             $stmt->execute([$voter['id']]);
  8.             if ($stmt->fetchColumn() !== null) {
  9.                 $pdo->rollBack();
  10.                 flash('You have already voted.', 'error');
  11.                 redirect('receipt.php');
  12.             }
  13.             $ins = $pdo->prepare('INSERT INTO votes (voter_id, position_id, candidate_id) VALUES (?, ?, ?)');
  14.             foreach ($choices as [$pid, $cid]) {
  15.                 $ins->execute([$voter['id'], $pid, $cid]);
  16.             }
  17.             $pdo->prepare('UPDATE voters SET voted_at = NOW() WHERE id = ?')->execute([$voter['id']]);
  18.             $pdo->commit();
  19.         } catch (Throwable $ex) {
  20.             $pdo->rollBack();
  21.             throw $ex;
  22.         }

Counting votes and finding winners. The results come from one LEFT JOIN with COUNT(), so candidates with zero votes still appear. The top N candidates win, where N is the position's "vote for" number, but if there is a tie exactly at the last seat, the tied candidates are marked Tie instead of picking one at random:

  1. /**
  2.  * Vote counts per candidate for one position, highest first.
  3.  * Winners are the top max_vote candidates; ties at the cut-off are flagged.
  4.  */
  5. function results_for(array $position): array {
  6.     $stmt = db()->prepare(
  7.         'SELECT c.id, c.full_name, c.partylist, COUNT(v.id) AS votes
  8.           FROM candidates c
  9.           LEFT JOIN votes v ON v.candidate_id = c.id
  10.          WHERE c.position_id = ?
  11.          GROUP BY c.id, c.full_name, c.partylist
  12.          ORDER BY votes DESC, c.full_name'
  13.     );
  14.     $stmt->execute([$position['id']]);
  15.     $rows = $stmt->fetchAll();
  16.     $max = (int)$position['max_vote'];
  17.     $cutoff = isset($rows[$max - 1]) ? (int)$rows[$max - 1]['votes'] : 0;
  18.     $tieAtCutoff = isset($rows[$max]) && (int)$rows[$max]['votes'] === $cutoff && $cutoff > 0;
  19.     foreach ($rows as $i => &$r) {
  20.         $r['votes'] = (int)$r['votes'];
  21.         $r['winner'] = $r['votes'] > 0 && ($i < $max) && !($tieAtCutoff && $r['votes'] === $cutoff);
  22.         $r['tie'] = $tieAtCutoff && $r['votes'] === $cutoff;
  23.     }
  24.     return $rows;
  25. }

Database Tables

The database has six tables: settings (election title and open/closed flag), admins, positions, candidates, voters and votes. These are the main ones:

  1. CREATE TABLE positions (
  2.   id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  3.   name VARCHAR(80) NOT NULL UNIQUE,
  4.   max_vote TINYINT UNSIGNED NOT NULL DEFAULT 1,
  5.   sort_order INT NOT NULL DEFAULT 0
  6. ) ENGINE=InnoDB;
  7.  
  8. CREATE TABLE candidates (
  9.   id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  10.   position_id INT UNSIGNED NOT NULL,
  11.   full_name VARCHAR(100) NOT NULL,
  12.   partylist VARCHAR(80) NOT NULL DEFAULT '',
  13.   course_year VARCHAR(40) NOT NULL DEFAULT '',
  14.   platform VARCHAR(255) NOT NULL DEFAULT '',
  15.   CONSTRAINT fk_cand_position FOREIGN KEY (position_id) REFERENCES positions(id)
  16. ) ENGINE=InnoDB;
  17.  
  18. CREATE TABLE voters (
  19.   id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  20.   student_id VARCHAR(20) NOT NULL UNIQUE,
  21.   full_name VARCHAR(100) NOT NULL,
  22.   course_year VARCHAR(40) NOT NULL DEFAULT '',
  23.   password_hash VARCHAR(255) NOT NULL,
  24.   voted_at DATETIME NULL DEFAULT NULL
  25. ) ENGINE=InnoDB;
  26.  
  27. CREATE TABLE votes (
  28.   id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  29.   voter_id INT UNSIGNED NOT NULL,
  30.   position_id INT UNSIGNED NOT NULL,
  31.   candidate_id INT UNSIGNED NOT NULL,
  32.   created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  33.   UNIQUE KEY uq_voter_candidate (voter_id, candidate_id),
  34.   KEY idx_candidate (candidate_id),
  35.   CONSTRAINT fk_vote_voter FOREIGN KEY (voter_id) REFERENCES voters(id),
  36.   CONSTRAINT fk_vote_position FOREIGN KEY (position_id) REFERENCES positions(id),
  37.   CONSTRAINT fk_vote_candidate FOREIGN KEY (candidate_id) REFERENCES candidates(id)
  38. ) ENGINE=InnoDB;

The UNIQUE (voter_id, candidate_id) key is a second safety net: even if the application had a bug, the database would refuse to store the same vote twice.

Project Structure

online-voting-system/
├── index.php          voter login
├── ballot.php         official ballot and vote saving
├── receipt.php        printable vote receipt
├── logout.php
├── config.php         database settings and time zone
├── database.sql       tables + sample data
├── test_app.py        automated end-to-end test
├── admin/
│   ├── login.php, logout.php
│   ├── index.php      dashboard, open/close voting, reset
│   ├── positions.php
│   ├── candidates.php
│   ├── voters.php
│   └── results.php
├── includes/          db connection, helpers, auth, header/footer
└── assets/style.css

How to Run the Online Voting System

Using XAMPP

  1. Download and extract the zip file below.
  2. Copy the online-voting-system folder into C:\xampp\htdocs\.
  3. Start Apache and MySQL in the XAMPP Control Panel.
  4. Open http://localhost/phpmyadmin, click Import, choose database.sql and click Go. This creates the online_voting database with sample data.
  5. If your MySQL root account has a password, edit config.php.
  6. Voters: open http://localhost/online-voting-system/
    Admin: open http://localhost/online-voting-system/admin/

Using the PHP built-in server

mysql -u root -p < database.sql
php -S localhost:8000

Then open http://localhost:8000 for voters and http://localhost:8000/admin/ for the admin.

Default accounts

  • Admin: admin / admin123
  • Voters: 2026-0001 to 2026-0010 / voter123

Change these passwords before using the system for a real election. The time zone is set to Asia/Manila in config.php; change it if you are in another country.

Automated Tests

The zip includes test_app.py, which runs a whole election against the running app: logins, ballot rules, double voting, results, and every admin page. Import a fresh database.sql, start the app, and run python3 test_app.py http://localhost:8000. This is the output from the version in the zip:

PASS guest redirected from ballot to login
PASS guest redirected from admin to admin login
PASS wrong voter password rejected
PASS voter login opens ballot
PASS ballot lists 5 positions
PASS councilor uses checkboxes
PASS 4 councilors rejected (max 3)
PASS candidate from another position rejected
PASS empty ballot rejected
PASS bad CSRF token rejected
PASS valid ballot accepted -> receipt
PASS receipt shows 7 choices
PASS second vote blocked
PASS ballot page redirects after voting
PASS 4 more voters voted (one abstained on a position)
PASS wrong admin password rejected
PASS admin login -> dashboard
PASS turnout 5 of 10 = 50%
PASS President: Andrea 3, Miguel 2
PASS Andrea marked winner
PASS Secretary: Bea 2, Kevin 2 -> tie, no winner
PASS Councilor: Daniel 3, Erika 3, Francis 2, Grace 2, Harold 2
PASS Councilor: Daniel and Erika win, three-way tie for 3rd seat
PASS President percentage 60%
PASS candidate with votes cannot be deleted
PASS add candidate
PASS position with candidates cannot be deleted
PASS add position
PASS duplicate position blocked
PASS register voter
PASS duplicate Student ID blocked
PASS filter not-yet-voted shows 6
PASS search voters
PASS close voting
PASS new voter can log in, sees closed notice
PASS vote rejected while closed
PASS reopen voting
PASS reset needs RESET confirmation
PASS reset election
PASS admin logout
PASS admin pages locked after logout

41 passed, 0 failed

Ideas to Extend the Project

  • Import the voter list from a CSV file exported from the registrar.
  • Add candidate photos with a secure image upload.
  • Send each voter a one-time password by email instead of a shared initial password.
  • Add per-department elections, where a voter only sees the representatives for their own course.
  • Show a live turnout chart by course and year level.
  • For a fully secret ballot, remove voter_id from the votes table and keep only the voted_at flag on the voter (you lose the per-voter receipt).

Conclusion

This Online Voting System covers a complete election in one small PHP and MySQL project: voter and admin logins, a ballot that supports multi-seat positions and abstaining, a transaction that guarantees one vote per student, a live turnout dashboard, and results that detect winners and ties. Download the source code, run a test election with the sample data, then change the positions, candidates and branding for your school. It is a solid base for a capstone project, and the extension ideas above are a good way to make it your own.

Note: Due to the size or complexity of this submission, the author has submitted it as a .zip file to shorten your download time. After downloading it, you will need a program like Winzip to decompress it.

Virus note: All files are scanned once-a-day by SourceCodester.com for viruses, but new viruses come out every day, so no prevention program can catch 100% of them.

FOR YOUR OWN SAFETY, PLEASE:

1. Re-scan downloaded files using your personal virus checker before using it.
2. NEVER, EVER run compiled files (.exe's, .ocx's, .dll's etc.)--only run source code.