AJAX Poll
# What We Are Building In simple terms: A poll asks visitors a question — "Which language do you want to learn next?" — and shows the results as bars with percentages. With AJAX, the vote is sent in t
What We Are Building
In simple terms: A poll asks visitors a question — "Which language do you want to learn next?" — and shows the results as bars with percentages. With AJAX, the vote is sent in the background and the results appear instantly, without reloading the page.
Technically: The poll consists of a PHP endpoint that records a vote and returns the current results as JSON, a storage layer that updates vote counts safely under concurrent requests, a rule that limits each visitor to one vote, and JavaScript that submits the vote with fetch() and renders the result bars. This lesson stores votes in a JSON file with file locking (see PHP File Create/Write) and then shows the database version used in production.
The Poll Definition
<?php
// src/poll.php
const POLL_QUESTION = 'Which language do you want to learn next?';
const POLL_OPTIONS = [
'php' => 'PHP',
'python' => 'Python',
'javascript' => 'JavaScript',
];
Options are identified by fixed keys. Only these keys are ever accepted as votes.
Recording Votes Safely
Several visitors may vote at the same moment. Reading the counts, adding one, and writing them back must happen as one locked operation; otherwise, two simultaneous votes could both read "12" and both write "13", losing a vote.
<?php
// src/poll-storage.php
function recordVote(string $file, ?string $option, array $options): array
{
$handle = fopen($file, 'c+');
if ($handle === false) {
throw new RuntimeException('Cannot open poll storage.');
}
try {
flock($handle, LOCK_EX);
$contents = stream_get_contents($handle);
$votes = $contents === '' ? [] : json_decode($contents, true, 4, JSON_THROW_ON_ERROR);
$votes = array_merge(array_fill_keys(array_keys($options), 0), $votes);
if ($option !== null) {
if (!array_key_exists($option, $options)) {
throw new InvalidArgumentException('Unknown option.');
}
$votes[$option]++;
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($votes, JSON_THROW_ON_ERROR));
fflush($handle);
}
return $votes;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
function pollResults(array $votes, array $options): array
{
$total = array_sum($votes);
$results = [];
foreach ($options as $key => $label) {
$count = $votes[$key] ?? 0;
$results[] = [
'option' => $key,
'label' => $label,
'votes' => $count,
'percent' => $total === 0 ? 0 : (int) round($count / $total * 100),
];
}
return ['results' => $results, 'total' => $total];
}
Testing the Logic
<?php
require __DIR__ . '/src/poll.php';
require __DIR__ . '/src/poll-storage.php';
$file = sys_get_temp_dir() . '/poll-test.json';
file_put_contents($file, json_encode(['php' => 12, 'python' => 7, 'javascript' => 5]));
$votes = recordVote($file, 'php', POLL_OPTIONS);
echo json_encode(pollResults($votes, POLL_OPTIONS)) . "\n";
try {
recordVote($file, 'cobol', POLL_OPTIONS);
} catch (InvalidArgumentException $e) {
echo "Rejected: " . $e->getMessage() . "\n";
}
unlink($file);
Output
{"results":[{"option":"php","label":"PHP","votes":13,"percent":52},{"option":"python","label":"Python","votes":7,"percent":28},{"option":"javascript","label":"JavaScript","votes":5,"percent":20}],"total":25}
Rejected: Unknown option.
How It Works
fopen($file, 'c+')opens (or creates) the file without truncating it;flock(LOCK_EX)waits until no other request holds the lock.- The counts are read, merged with zero defaults for all options, incremented, and written back — all while the lock is held.
- Passing
nullas the option only reads the current counts (for displaying results). pollResults()converts counts into percentages: 13 of 25 votes = 52%.- Unknown options are rejected before anything is written.
The Endpoint: api/poll.php
<?php
declare(strict_types=1);
require __DIR__ . '/../src/poll.php';
require __DIR__ . '/../src/poll-storage.php';
require __DIR__ . '/../src/json-response.php';
session_start();
$storage = __DIR__ . '/../storage/poll-votes.json';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
jsonResponse(200, pollResults(recordVote($storage, null, POLL_OPTIONS), POLL_OPTIONS));
}
$sessionToken = $_SESSION['csrf_token'] ?? '';
$sentToken = $_POST['csrf_token'] ?? '';
if ($sessionToken === '' || !is_string($sentToken) || !hash_equals($sessionToken, $sentToken)) {
jsonResponse(419, ['error' => 'Session expired. Please reload the page.']);
}
if (!empty($_SESSION['poll_voted'])) {
jsonResponse(409, ['error' => 'You have already voted.'] + pollResults(recordVote($storage, null, POLL_OPTIONS), POLL_OPTIONS));
}
$option = $_POST['option'] ?? null;
if (!is_string($option) || !array_key_exists($option, POLL_OPTIONS)) {
jsonResponse(422, ['error' => 'Please choose one of the options.']);
}
$votes = recordVote($storage, $option, POLL_OPTIONS);
$_SESSION['poll_voted'] = true;
jsonResponse(200, pollResults($votes, POLL_OPTIONS));
Status Codes Used
| Status | Situation |
|---|---|
| 200 | Results returned (GET) or vote recorded (POST) |
| 409 Conflict | This visitor has already voted — results are included so they can still be shown |
| 419 | Missing or invalid CSRF token |
| 422 | No valid option selected |
About "One Vote per Visitor"
A session flag (or cookie) stops accidental double votes, but anyone can clear cookies and vote again. For polls where results matter, require users to log in and store one vote per user in the database, protected by a unique index:
CREATE TABLE poll_votes (
poll_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
option_key VARCHAR(30) NOT NULL,
voted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (poll_id, user_id)
);
A second vote from the same user then fails with a duplicate-key error (1062), which the endpoint translates into the 409 response. Counting becomes SELECT option_key, COUNT(*) FROM poll_votes WHERE poll_id = ? GROUP BY option_key, and the database handles concurrency.
The Poll Widget
<form id="poll" action="api/poll.php" method="post">
<fieldset>
<legend>Which language do you want to learn next?</legend>
<label><input type="radio" name="option" value="php"> PHP</label>
<label><input type="radio" name="option" value="python"> Python</label>
<label><input type="radio" name="option" value="javascript"> JavaScript</label>
</fieldset>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<button type="submit">Vote</button>
<p id="poll-message" aria-live="polite"></p>
</form>
<div id="poll-results" hidden></div>
<script>
const form = document.getElementById('poll');
const message = document.getElementById('poll-message');
const resultsBox = document.getElementById('poll-results');
function showResults({ results, total }) {
resultsBox.replaceChildren(...results.map(result => {
const row = document.createElement('div');
row.className = 'poll-row';
const label = document.createElement('span');
label.textContent = `${result.label}: ${result.percent}% (${result.votes})`;
const bar = document.createElement('div');
bar.className = 'poll-bar';
bar.style.width = `${result.percent}%`;
row.append(label, bar);
return row;
}), Object.assign(document.createElement('p'), { textContent: `${total} votes in total` }));
resultsBox.hidden = false;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
message.textContent = '';
try {
const response = await fetch(form.action, { method: 'POST', body: new FormData(form) });
const data = await response.json();
if (response.ok || response.status === 409) {
showResults(data);
form.querySelector('fieldset').disabled = true;
form.querySelector('button').hidden = true;
}
if (!response.ok) {
message.textContent = data.error;
}
} catch {
message.textContent = 'Your vote could not be sent. Please try again.';
}
});
</script>
Result in the Browser
After voting for PHP:
PHP: 52% (13) ██████████████████████████
Python: 28% (7) ██████████████
JavaScript: 20% (5) ██████████
25 votes in total
How It Works
- The form works without JavaScript too: it posts to the same endpoint (a server-rendered results page could be added for that case).
- With JavaScript,
fetch()submits theFormData— including the CSRF token — in the background. - On success (or "already voted"), the results are drawn as bars whose width is the percentage. All text is inserted with
textContent. - The voting controls are disabled after a vote to prevent repeated submissions.
Common Mistakes
Read-Modify-Write Without Locking
Effect: Simultaneous votes overwrite each other.
Fix: Lock the file during the whole update, or use database counters/rows.
Trusting the Submitted Option
Effect: Arbitrary keys are added to the results.
Fix: Accept only keys from the allow-list of options.
Relying on Cookies to Prevent Repeat Voting
Effect: Results can be manipulated easily.
Fix: Require login and enforce one vote per user with a unique index.
Percentages That Do Not Add Up
Effect: Rounding can produce totals of 99% or 101%.
Fix: Accept small rounding differences, or show one decimal place.
Best Practices
- Validate votes against a fixed list of options.
- Update counts atomically: file locks for small sites, database rows with unique keys for real polls.
- Protect the voting endpoint with CSRF tokens and return clear status codes.
- Render results safely and keep the form usable without JavaScript.
- Require authentication where poll results must be trustworthy.
Official reference: https://www.php.net/manual/en/function.file-put-contents.php