AJAX Poll

JA JASWANT Updated 19 Oct 2026
6 min read ·Lesson 114 of 143
In this post

# 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

  1. fopen($file, 'c+') opens (or creates) the file without truncating it; flock(LOCK_EX) waits until no other request holds the lock.
  2. The counts are read, merged with zero defaults for all options, incremented, and written back — all while the lock is held.
  3. Passing null as the option only reads the current counts (for displaying results).
  4. pollResults() converts counts into percentages: 13 of 25 votes = 52%.
  5. 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

StatusSituation
200Results returned (GET) or vote recorded (POST)
409 ConflictThis visitor has already voted — results are included so they can still be shown
419Missing or invalid CSRF token
422No 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

  1. The form works without JavaScript too: it posts to the same endpoint (a server-rendered results page could be added for that case).
  2. With JavaScript, fetch() submits the FormData — including the CSRF token — in the background.
  3. On success (or "already voted"), the results are drawn as bars whose width is the percentage. All text is inserted with textContent.
  4. 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning
Ask about this post
AI Ask about this post

Ask questions about AJAX Poll and get answers drawn from it.

Signed-in readers only.