Previous Article
Generate a Valid RSS Feed with PHP (Escaping, GUIDs and Caching)
26 February 2018
A like/dislike widget looks trivial and hides three genuinely interesting problems: representing a three-state toggle, preventing double votes under concurrency, and identifying who is voting without a login.
This guide builds one properly — with a schema that makes duplicate votes impossible at the database level rather than hoping application logic catches them.
vote column makes every transition one statement.CREATE TABLE votes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, item_id BIGINT UNSIGNED NOT NULL, voter_key VARBINARY(32) NOT NULL, -- hashed identity, see below vote TINYINT NOT NULL, -- 1 = like, -1 = dislike created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- This is what actually prevents double voting. UNIQUE KEY uniq_item_voter (item_id, voter_key), KEY idx_item_vote (item_id, vote), CONSTRAINT chk_vote CHECK (vote IN (-1, 1)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The unique constraint is the important line. The original version of this article checked for an existing vote with a SELECT, then inserted — which is a classic race: two simultaneous clicks both pass the check and both insert. A unique index makes the second insert fail at the database, where it cannot be raced.
Storing the vote as 1 / -1 rather than 1 / 2 means the score is just SUM(vote), and counts are a single grouped query rather than one query per state.
The original keyed votes on $_SERVER['REMOTE_ADDR']. That does not work:
Use the logged-in user ID where you have one, and a signed cookie otherwise:
function voterKey(): string
{
if (!empty($_SESSION['user_id'])) {
return hash('sha256', 'user:' . $_SESSION['user_id'], true);
}
// Anonymous: a random id in a long-lived cookie.
if (empty($_COOKIE['vk'])) {
$id = bin2hex(random_bytes(16));
setcookie('vk', $id, [
'expires' => time() + 86400 * 365,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
} else {
$id = $_COOKIE['vk'];
}
return hash('sha256', 'anon:' . $id, true);
}
Be clear-eyed: a cookie is trivially cleared, so anonymous voting can always be gamed. That is true of every anonymous voting system, YouTube's included. If votes genuinely matter, require an account. Otherwise accept the imprecision and add rate limiting.
Hashing means the raw identifier never sits in your database, which keeps a compromised backup from linking votes to users.
<?php
declare(strict_types=1);
header('Content-Type: application/json');
$itemId = filter_input(INPUT_POST, 'item_id', FILTER_VALIDATE_INT);
$vote = filter_input(INPUT_POST, 'vote', FILTER_VALIDATE_INT);
if ($itemId === null || $itemId === false || !in_array($vote, [1, -1, 0], true)) {
http_response_code(400);
exit(json_encode(['error' => 'Invalid request']));
}
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid token']));
}
$key = voterKey();
if ($vote === 0) {
// Withdrawing a vote.
$pdo->prepare('DELETE FROM votes WHERE item_id = ? AND voter_key = ?')
->execute([$itemId, $key]);
} else {
// Insert, or flip an existing vote — atomic, no SELECT-then-INSERT race.
$pdo->prepare(
'INSERT INTO votes (item_id, voter_key, vote) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE vote = VALUES(vote)'
)->execute([$itemId, $key, $vote]);
}
echo json_encode(tallyFor($pdo, $itemId, $key), JSON_THROW_ON_ERROR);
That replaces the original's six queries per click with one. And because ON DUPLICATE KEY UPDATE is atomic, two concurrent requests cannot produce two rows.
function tallyFor(PDO $pdo, int $itemId, string $voterKey): array
{
$stmt = $pdo->prepare(
'SELECT
SUM(vote = 1) AS likes,
SUM(vote = -1) AS dislikes,
MAX(CASE WHEN voter_key = ? THEN vote END) AS my_vote
FROM votes
WHERE item_id = ?'
);
$stmt->execute([$voterKey, $itemId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$likes = (int) ($row['likes'] ?? 0);
$dislikes = (int) ($row['dislikes'] ?? 0);
$total = $likes + $dislikes;
return [
'likes' => $likes,
'dislikes' => $dislikes,
'my_vote' => isset($row['my_vote']) ? (int) $row['my_vote'] : 0,
// Guard the division — the original crashed on the first vote of a new item.
'percent' => $total > 0 ? round($likes / $total * 100) : 0,
];
}
One query returns both counts and the current user's vote. If an item accumulates a lot of votes, denormalise: keep like_count and dislike_count columns on the items table and update them in the same transaction, so rendering never aggregates.
const widget = document.querySelector('.vote');
const itemId = widget.dataset.itemId;
let state = { likes: 0, dislikes: 0, my_vote: 0 };
widget.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-vote]');
if (!btn) return;
const clicked = Number(btn.dataset.vote);
// Clicking your existing vote withdraws it.
const next = state.my_vote === clicked ? 0 : clicked;
const previous = { ...state };
applyOptimistic(next); // update the UI immediately
try {
const res = await fetch('/api/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ item_id: itemId, vote: next, csrf: CSRF_TOKEN }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state = await res.json();
} catch {
state = previous; // roll back on failure
showToast('Could not save your vote.');
}
render();
});
function applyOptimistic(next) {
// Remove the old vote, apply the new one.
if (state.my_vote === 1) state.likes--;
if (state.my_vote === -1) state.dislikes--;
if (next === 1) state.likes++;
if (next === -1) state.dislikes++;
state.my_vote = next;
render();
}
function render() {
widget.querySelector('[data-vote="1"]').setAttribute('aria-pressed', state.my_vote === 1);
widget.querySelector('[data-vote="-1"]').setAttribute('aria-pressed', state.my_vote === -1);
widget.querySelector('.like-count').textContent = state.likes;
widget.querySelector('.dislike-count').textContent = state.dislikes;
const total = state.likes + state.dislikes;
widget.querySelector('.bar-fill').style.width =
(total ? (state.likes / total) * 100 : 0) + '%';
}
Updating the UI before the server responds — optimistic updating — is what makes the widget feel instant. Keeping the previous state lets you roll back cleanly when the request fails.
<div class="vote" data-item-id="42">
<button type="button" data-vote="1" aria-pressed="false">
<svg aria-hidden="true" focusable="false" width="20" height="20"><use href="#icon-up" /></svg>
<span class="sr-only">Like</span>
<span class="like-count">0</span>
</button>
<button type="button" data-vote="-1" aria-pressed="false">
<svg aria-hidden="true" focusable="false" width="20" height="20"><use href="#icon-down" /></svg>
<span class="sr-only">Dislike</span>
<span class="dislike-count">0</span>
</button>
<div class="bar" role="img" aria-label="Rating"><div class="bar-fill"></div></div>
</div>
Use real <button> elements with aria-pressed — it is exactly the right ARIA state for a toggle, and screen readers announce "pressed" when the vote is active. Do not colour the active state alone; add a fill or outline change so it is distinguishable without colour vision.
extract($_POST). The original did. It creates variables from arbitrary user input, so a request containing ?pdo=x overwrites your database handle. There is no safe use of it on request data.$pageID straight into SQL.1 / -1 with a unique key on (item_id, voter_key).SELECT beforehand.INSERT … ON DUPLICATE KEY UPDATE handles every transition.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.