Skip to main content
Being Idea Innovations
Build a Like / Dislike Voting System with PHP and MySQL
Back to Blog

Build a Like / Dislike Voting System with PHP and MySQL

14 March 20186 min readMySql
Share:

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.

The three-state toggle

State transitions between no vote, liked and disliked none no row liked vote = 1 disliked vote = -1 click like click like again click dislike click dislike again click dislike click like
Clicking the button you already chose removes your vote. Clicking the opposite one switches it. Modelling this as a single signed vote column makes every transition one statement.

The schema

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.

Identifying the voter

The original keyed votes on $_SERVER['REMOTE_ADDR']. That does not work:

  • Shared IPs collide. An office, a school, a café or anyone behind carrier-grade NAT shares one address — the first person to vote blocks everyone else on that network.
  • IPs change. Mobile users get a new address regularly and can vote repeatedly.
  • IP addresses are personal data under GDPR, so storing them in plain text for this is disproportionate.

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.

Recording a vote: one statement

<?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.

Reading the tally

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.

The front end

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.

Markup and accessibility

<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.

Security notes

  • Never use 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.
  • Use prepared statements. The original concatenated $pageID straight into SQL.
  • Require a CSRF token. Otherwise any site can make your logged-in users vote.
  • Rate limit. A cap of, say, 30 votes per key per hour blunts scripted abuse.

Summary

  • Store votes as 1 / -1 with a unique key on (item_id, voter_key).
  • Let the unique constraint prevent double votes, not a SELECT beforehand.
  • One INSERT … ON DUPLICATE KEY UPDATE handles every transition.
  • Identify voters by user ID or a hashed cookie — never by IP.
  • Guard the percentage calculation against division by zero.
  • Update optimistically and roll back on error.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers