Upvote and Downvote WordPress Posts

In this WordPress tutorial we’ll build some upvote and downvote buttons that can be added to any post. We’ll start with a requirement specification, break it down into components, and then code it.

If you just want the code itself, you can skip over the first part of this tutorial and start copying-and-pasting.

importantTo follow this tutorial, you need to be using a custom child theme so you can edit functions.php.

Vote on WordPress Posts

...
...
Demo up/down vote controls

Requirements

  • Website visitors can click “up vote” or “down vote” on WordPress posts.
  • Each visitor can vote once on each post.
  • Protect against flood attacks by limiting the rate at which visitors can vote on posts across the site.
    • Requiring a gap of at least 20 seconds between votes should be sufficient.
  • The code needs to work when full page caching is enabled.
  • Visitors can vote on post archive pages as well as single posts, so there can be multiple up/down vote buttons on any given page.

Deal with the Key Requirements

The page-caching requirement is interesting. It means we can’t simply render the up/down counts in the page itself, because as soon as someone votes we’d need to flush the cache. It’s much simpler to render a placeholder HTML snippet instead. After the page has loaded, we can do some JavaScript magic in the browser to fetch the current counts. We can also use JavaScript to register new votes without having to reload the entire page.

To protect people voting on a post more than once, or voting too quickly across the site, we’re going to use WordPress transients. These are temporary objects that “live” for a fixed duration. When someone votes on a post, we’ll create a “key” that’s specific to the user’s IP address and the Post ID. Then we create the actual transient and then give it “x” seconds to “live”. If the user tries to vote on that post, but there’s already a transient with that key, we’ll know they’re trying to vote again and we can block them. We’ll use two transients… a short-term transient of 20 seconds to stop people flooding the site… and a longer-term per-post transient to stop repeat voting.

Adding the placeholder HTML to the pages is quite easy – we can just hook the_content and/or the_excerpt filters to “inject” a little HTML snippet into any posts and/or excerpts we want.

infoWhen we’re developing the code, we’re going to want to be able to vote multiple times (to test) so we need to add some special conditions for “Administrators”. It’ll also be handy if administrators can reset vote counts for any post.

Upvote and Downvote buttons
Upvote, Downvote & Reset buttons

Plan the Logic

We’re going to have two chunks of code in this project. One chunk will be in PHP, so it’ll run on the server. The other chunk will be in JavaScript and will handle the voting buttons and counters.

The PHP Code

We’re going to need some support functions to do the following:

  • Create a unique key so we can record when someone votes. We’ll use two types of key. One type of key will be a combination of the client’s IP address and the Post Id, used to record that a user has voted on a post. The other will be a shorter-lived key that just records when a user last voted on the site – this will stop someone being able to vote 1,000 per second in a flood attack.
  • has_voted_recently() and has_already_voted_on_post($post_id) will make it easy for us to check if a user is “allowed” to vote.
  • register_vote_on_post() will record that a user has voted, by creating the two transients.
  • get_voting_html() will return the HTML voting button snippet. This will be wrapped in a div that has a property like data-post-id="123" so our JavaScript code can pick up which Post Id we’re interacting with when a user clicks one of the vote buttons. This lets us have multiple voting snippets on the page, each relating to a different post.
  • We’ll register an Ajax Action Handler to handle when the browser wants to register a new vote.

The JavaScript Code

The JavaScript/jQuery will be quite lightweight:

  • Attach jQuery click() handlers to all the voting buttons on the page.
  • Find all the HTML voting snippets on the page, extract a list of unique Post IDs, and fetch the vote counts for each post.
  • When someone clicks a vote button, POST some data to the server with the Post Id and whether it’s an up or a down vote. If there is an error, alert the user.
  • When the server responds with voting counts for a post, we need to find all HTML voting snippets related to that post and update the counts.

Add the Scaffolding Code

In your custom child theme, create an empty file called wpt-vote-on-post.php and paste the following into it.

<?php

/**
 * WP Tutorials : Vote on Post (wptvop)
 *
 * https://wp-tutorials.tech/add-functionality/upvote-and-downvote-posts/
 */

defined('WPINC') || die();

const WPTVOP_VOTE_BUTTONS_BEFORE_CONTENT = false;
const WPTVOP_VOTE_BUTTONS_AFTER_CONTENT = true;
const WPTVOP_ACTION_NAME = 'voteonpost';

// A short timeout between voting on different posts.
// This is to stop lots of votes coming in very quickly (flood protection).
const WPTVOP_SITE_TIME_BETWEEN_VOTES = 20; // seconds

// Stopping an IP address from voting on the same post
// twice in 36 hours should be finr.
const WPTVOP_POST_TIME_BETWEEN_VOTES = (HOUR_IN_SECONDS * 36); // seconds

Next, open functions.php and add the following to pull-in our code.

// WP Tutorials : Vote on Posts
require_once dirname(__FILE__) . '/wpt-vote-on-post.php';

Save those and check that your site still works (it should be fine).

Next, in your custom child theme, create a new folder called “wpt-vote-on-post”, and make three empty files in there:

  • vote-snippet.php
  • wpt-vote-on-post.css
  • wpt-vote-on-post.js
This tutorial's asset files
Our Vote-on-Post support files

Open vote-snippet.php and paste the following into it.

<?php

// Block direct access
defined('WPINC') || die();

?>
<div class="vote-action upvote">
    <a href="javascript:void(0)"><i class="fas fa-thumbs-up"></i></a>
    <span class="count">...</span>
</div>
<div class="vote-action downvote">
    <a href="javascript:void(0)"><i class="fas fa-thumbs-down"></i></a>
    <span class="count">...</span>
</div>
<?php
// For administrators, render a "reset counts" button.
if (current_user_can('administrator')) {
	echo '<div class="vote-action reset">';
	echo '<a href="javascript:void(0)"><i class="fas fa-trash-alt"></i></a>';
	echo '</div>'; // .reset
}

infoWe’ve used Font Awesome 5 for our icons. If you want to use your own images then you’ll need to change the Font Awesome HTML above: <i class="fas..."></i>. If you want to stay with Font Awesome 5, but your site/theme doesn’t have it, then check out our tutorial on self-hosting Font Awesome in your WordPress site.

Now add the following CSS into wpt-vote-on-post.css and save everything.

/**
 * WP Tutorials : Vote on Post (wptvop)
 *
 * https://wp-tutorials.tech/add-functionality/upvote-and-downvote-posts/
 */
.wptvop-container {
    display: flex;
    flex-direction: row;
    gap: 1em;
    font-size: 24pt;
}

.wptvop-container .count {
    display: inline-block;
    text-align: center;
    min-width: 2em;
    transition: 0.3s;
}

.wptvop-container.working * {
    cursor: wait;
}

.wptvop-container.working .count {
    opacity: 0.25;
}

.wptvop-container .upvote a {
    color: green;
}

.wptvop-container .downvote a {
    color: darkred;
}

.wptvop-container a {
    transition: 0.3s;
}

.wptvop-container a:hover {
    filter: brightness(1.5);
}

That’s our scaffolding in-place – we’ve got the main code file “wpt-vote-on-post.php”, and a folder called “wpt-vote-on-post” with our assets and PHP/HTML voting snippet file. Now we need to look at the two big chunks.

We’ll start with the PHP chunk…

The Back-End PHP Code

Open wpt-vote-on-post.php and paste the following into it, replacing everything that was already in there.

<?php

/**
 * WP Tutorials : Vote on Post (wptvop)
 *
 * https://wp-tutorials.tech/add-functionality/upvote-and-downvote-posts/
 */

defined('WPINC') || die();

const WPTVOP_VOTE_BUTTONS_BEFORE_CONTENT = false;
const WPTVOP_VOTE_BUTTONS_AFTER_CONTENT = true;
const WPTVOP_ACTION_NAME = 'voteonpost';

// A short timeout between voting on different posts.
// This is to stop lots of votes coming in very quickly (flood protection).
const WPTVOP_SITE_TIME_BETWEEN_VOTES = 20; // seconds

// Stopping an IP address from voting on the same post
// twice in 36 hours should be finr.
const WPTVOP_POST_TIME_BETWEEN_VOTES = (HOUR_IN_SECONDS * 36); // seconds

/**
 * Return true from this function on any page where you want voting to be
 * enabled.
 */
function is_voting_on_posts_enabled() {
	// Uncomment to allow voting on post archive pages.
	// return is_archive() || is_single() || is_home();

	// Only allow voting on single posts.
	return is_single();
}

/**
 * Get the client's IP address, in various different situations.
 * NOTE: There are security considerations here...
 * It might be advisible to only use $_SERVER['REMOTE_ADDR'] if you can.
 */
function wptvop_client_ip() {
	global $wptvop_client_ip;

	if (!is_null($wptvop_client_ip)) {
		// We've already discovered the browser's IP address.
	} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
		$wptvop_client_ip = filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP);
	} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
		$wptvop_client_ip = filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP);
	} else {
		$wptvop_client_ip = filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP);
	}

	return $wptvop_client_ip;
}

/**
 * Get a unique key for a client IP address.
 */
function wptvop_get_site_vote_key() {
	$key = null;

	if (!empty($client_ip = wptvop_client_ip())) {
		$key = 'vote_' . $client_ip . '_site';
	}

	return $key;
}

/**
 * Get a unique key for a client IP address and post.
 */
function wptvop_get_post_vote_key(int $post_id) {
	$key = null;

	if ($post_id <= 0) {
		// ...
	} elseif (empty($client_ip = wptvop_client_ip())) {
		// ...
	} else {
		$key = 'vote_' . $client_ip . '_' . $post_id;
	}

	return $key;
}

/**
 * Has this client IP address voted on any post on the site (very) recently?
 */
function has_voted_recently() {
	$has_voted = false;

	if (current_user_can('administrator')) {
		// ...
	} elseif (empty($transient_key = wptvop_get_site_vote_key())) {
		// ...
	} else {
		// If a transient with this exists, the IP address has voted recently.
		$has_voted = (get_transient($transient_key) !== false);
	}

	return $has_voted;
}

/**
 * Has this client IP address already voted on the specified post?
 */
function has_already_voted_on_post(int $post_id) {
	$has_voted = false;

	if (current_user_can('administrator')) {
		// ...
	} elseif (empty($transient_key = wptvop_get_post_vote_key($post_id))) {
		// ...
	} else {
		// If a transient with this exists, the IP address has voted recently.
		$has_voted = (get_transient($transient_key) !== false);
	}

	return $has_voted;
}

/**
 * Is the client IP address allwed to vote on this particular post?
 */
function is_allowed_to_vote_on_post(int $post_id) {
	$is_allowed = false;

	if (current_user_can('administrator')) {
		// Administrators can always vote.
		$is_allowed = true;
	} elseif (empty(wptvop_client_ip())) {
		// If we can't figure out a client's IP address,
		// they're not allowed to vote.
	} elseif (has_voted_recently()) {
		// ...
	} elseif (has_already_voted_on_post($post_id)) {
		// ...
	} else {
		$is_allowed = true;
	}

	return $is_allowed;
}

/**
 * Record that someone's IP address has voted on a post.
 */
function register_vote_on_post(int $post_id) {
	if (is_allowed_to_vote_on_post($post_id)) {
		set_transient(wptvop_get_site_vote_key(), '1', WPTVOP_SITE_TIME_BETWEEN_VOTES);
		set_transient(wptvop_get_post_vote_key($post_id), '1', WPTVOP_POST_TIME_BETWEEN_VOTES);
	}
}

/**
 * Load the HTML voting snippet and return it as HTML.
 */
function wptvop_get_voting_html() {
	ob_start();

	printf('<div class="wptvop-container working" data-post-id="%d">', get_the_ID());
	include dirname(__FILE__) . '/wpt-vote-on-post/vote-snippet.php';
	echo '</div>'; // .wptvop-container

	$html = ob_get_clean();

	return $html;
}

/**
 * If we're rendering a page where voting is enabled, enqueue our
 * frontend assets.
 */
function wptvop_enqueue_scripts() {
	if (is_voting_on_posts_enabled()) {
		$handle = 'wptvop';
		$base_uri = get_stylesheet_directory_uri();
		$theme_version = wp_get_theme()->get('Version');

		wp_enqueue_style(
			$handle,
			$base_uri . '/wpt-vote-on-post/wpt-vote-on-post.css',
			null, // We don't depend on any other styles.
			$theme_version
		);

		wp_enqueue_script(
			$handle,
			$base_uri . '/wpt-vote-on-post/wpt-vote-on-post.js',
			array('jquery'), // Our script needs jquery to be loaded.
			$theme_version
		);

		// Pass the Ajax URL and the name of our voting action
		// to our JavaScript.
		wp_localize_script(
			$handle,
			'wptvopData',
			array(
				'action' => WPTVOP_ACTION_NAME,
				'url' => admin_url('admin-ajax.php'),
			)
		);
	}
}
add_action('wp_enqueue_scripts', 'wptvop_enqueue_scripts');

/**
 * Insert the voting snippet into a post's content.
 */
function wptvop_the_content($content) {
	if (is_voting_on_posts_enabled()) {
		$html = wptvop_get_voting_html();

		if (WPTVOP_VOTE_BUTTONS_BEFORE_CONTENT) {
			$content = $html . $content;
		}

		if (WPTVOP_VOTE_BUTTONS_AFTER_CONTENT) {
			$content = $content . $html;
		}
	}

	return $content;
}
add_filter('the_content', 'wptvop_the_content');

/**
 * Insert the voting snippet into a post's excerpt.
 */
function wptvop_the_excerpt($excerpt) {
	if (is_voting_on_posts_enabled()) {
		$html = wptvop_get_voting_html();
		$excerpt = $html . $excerpt;
	}

	return $excerpt;
}
add_filter('the_excerpt', 'wptvop_the_excerpt');

/**
 * The core function is an action handler for:
 * wp_ajax_voteonpost
 * wp_ajax_nopriv_voteonpost
 */
function wptvop_process_vote() {
	$is_up_vote = false;
	$is_down_vote = false;
	$is_reset = false;
	$post_id = 0;
	$status_code = 400; // HTTP Response 400 Bad Request
	$response = array();

	// Safely fetch the options from the incoming POST data.
	if (array_key_exists('isUpVote', $_POST)) {
		$is_up_vote = filter_var($_POST['isUpVote'], FILTER_VALIDATE_BOOLEAN);
	}

	if (array_key_exists('isDownVote', $_POST)) {
		$is_down_vote = filter_var($_POST['isDownVote'], FILTER_VALIDATE_BOOLEAN);
	}

	if (array_key_exists('isReset', $_POST)) {
		$is_reset = filter_var($_POST['isReset'], FILTER_VALIDATE_BOOLEAN);
	}

	if (array_key_exists('postId', $_POST)) {
		$post_id = intval($_POST['postId']);
	}

	if (get_post_status($post_id) === false) {
		// Bad post id - don't go any further.
		$post_id = 0;
	} elseif ($is_up_vote && $is_down_vote) {
		// Bad parameters. Can't vote up and down at the same time.
	} elseif ($is_reset && current_user_can('administrator')) {
		update_post_meta($post_id, 'up_vote_count', 0);
		update_post_meta($post_id, 'down_vote_count', 0);
	} elseif (!$is_up_vote && !$is_down_vote) {
		// No vote has been cast. This is OK, but don't do anything.
	} elseif (has_already_voted_on_post($post_id)) {
		$response['message'] = "You've already voted on this post.";
	} elseif (has_voted_recently()) {
		$response['message'] = "Slow down a bit with your voting.";
	} elseif ($is_up_vote) {
		$old_count = intval(get_post_meta($post_id, 'up_vote_count', true));
		update_post_meta($post_id, 'up_vote_count', $old_count + 1);
		register_vote_on_post($post_id);
	} elseif ($is_down_vote) {
		$old_count = intval(get_post_meta($post_id, 'down_vote_count', true));
		update_post_meta($post_id, 'down_vote_count', $old_count + 1);
		register_vote_on_post($post_id);
	} else {
		// We should never end up in here!
	}

	if ($post_id > 0) {
		$response['postId'] = $post_id;
		$response['upVotes'] = intval(get_post_meta($post_id, 'up_vote_count', true));
		$response['downVotes'] = intval(get_post_meta($post_id, 'down_vote_count', true));
		$status_code = 200; // HTTP Response 200 OK
	}

	wp_send_json($response, $status_code);
	exit;
}
add_action('wp_ajax_' . WPTVOP_ACTION_NAME, 'wptvop_process_vote');
add_action('wp_ajax_nopriv_' . WPTVOP_ACTION_NAME, 'wptvop_process_vote');

It might look like a lot of code at first glance, but if you break it down into sections, it’s not scary and follows a standard pattern of having several small support functions, and a bigger/core function. In this case, the core function is wptvop_process_vote() and it handles our two actions “wp_ajax_voteonpost” and “wp_ajax_nopriv_voteonpost”. The “nopriv” version of the action is raised for non-logged-in (non-privileged) users, and the other is raised when a user is logged-in.

Splitting the code into smaller support functions makes the core function really easy to read. If you look through the main if/elseif/else block, you’ll see the tests are for things like has_already_voted_on_post() and has_voted_recently(), so someone who might not be a coder (e.g. a designer) should be able to read the code (and maybe make changes to it).

The logic of the core function breaks down like this:

  • Fetch parameters from PHP’s $_POST array and use them as our input parameters.
  • Check that the parameters are valid/sane.
  • If the user is trying to reset this post’s votes, and the user is an administrator, then…
    • Reset this post’s vote counts.
  • …else, if the user is trying to record a vote, then…
    • Check that the user is authorised to vote.
    • Change the number of votes saved against the post using update_post_meta().
    • Record that the user (IP address) has just voted for this post.
  • Return the current votes for this post to the browser, using wp_send_json()

A Note About Security

Usually, you’d use a WordPress nonce (nonce=”Number used ONCE”) when dealing with Ajax data to verify that incoming POST data haven’t been sent by some dodgy hack-bot. The trouble with doing that when not logged-in is that the page will probably be cached, so everybody would get the same nonce until the cached page expired. So… We can’t use a nonce here, and we have to be very careful when taking data out of PHP’s $_POST array. We’re fairly safe here, because we’re not capturing any string data, but bear in mind that it’s unusual to write an Ajax handler without using a nonce.

The JavaScript Code

Finally, we need to write the browser-based JavaScript code. There’s not much to this, so just open “wpt-vote-on-post/wpt-vote-on-post.js” and paste the following into it:

/**
 * WP Tutorials : Vote on Post (wptvop)
 *
 * https://wp-tutorials.tech/add-functionality/upvote-and-downvote-posts/
 */
(function($) {
    'use strict';
    $(window).on('load', function() {
        console.log('Vote on Post  : load');

        // Don't run any of our code if wptvopData hasn't been
        // set by calling wp_localize_script().
        if (typeof wptvopData != 'undefined') {
            console.log('Vote on Post  : init');

            // Connect the link elements' click listeners.
            $('.wptvop-container a').click(function(event) {
                var actionElement = $(this.closest('.vote-action'));
                var postId = $(this).closest('[data-post-id]').data('post-id');

                if (actionElement.hasClass('upvote')) {
                    registerVote(postId, true, false, false);
                } else if (actionElement.hasClass('downvote')) {
                    registerVote(postId, false, true, false);
                } else if (actionElement.hasClass('reset')) {
                    registerVote(postId, false, false, true);
                } else {
                    // Unknown action. Do nothing.
                }
            });

            // Main entry point for the code. For each vote snippet container,
            // get its data-post-id property. If we've not seen this postId
            // before, call registerVote() with only a postId (no voting),
            // which will cause the server to return the  current up/down counts.
            var uniquePostIds = [];
            $('.wptvop-container').each(function(index, el) {
                var postId = $(this).data('post-id');

                if (!uniquePostIds.includes(postId)) {
                    // Make a note that we've now "seen" this postId.
                    uniquePostIds.push(postId);

                    // Fetch the current counts for this postId.
                    registerVote(postId);
                }
            });


            // Our core funciton to register votes. If you call this with only
            // a postId then it will just fetch the up/down counts for that post.
            function registerVote(postId, isUpVote, isDownVote, isReset) {
                var request = {
                    action: wptvopData.action,
                    postId: parseInt(postId),
                    isUpVote: Boolean(isUpVote),
                    isDownVote: Boolean(isDownVote),
                    isReset: Boolean(isReset),
                };

                // Add the "working" css class to all voting containers that
                // match this postId.
                $(`.wptvop-container[data-post-id="${postId}"]`).addClass('working');

                // POST the "request" object to the server.
                $.post(wptvopData.url, request)
                    .done(function(response) {
                        // Success. Update the up/down counts.
                        $(`.wptvop-container[data-post-id="${postId}"] .upvote .count`).text(response.upVotes);
                        $(`.wptvop-container[data-post-id="${postId}"] .downvote .count`).text(response.downVotes);
                    })
                    .fail(function(response) {
                        // Fail.
                        console.log('Voting Error');
                    })
                    .always(function(response) {
                        // This code always runs, regardless of whether we
                        // succeeded or failed.

                        // If the server returned an error message,
                        // show it to the user.
                        if (response.message) {
                            alert(response.message);
                        }

                        // Remove the "working" css class from all voting containers that
                        // match this postId.
                        $(`.wptvop-container[data-post-id="${postId}"]`).removeClass('working');
                    });
            }
        }

    });
})(jQuery);

Again, this code follows a standard structure…

  1. Sanity check the input parameters.
  2. Define the support functions and/or event-listeners.
  3. Define the main core function : registerVote()
  4. Call the main entry point, to start things off.

Wrapping Up

When you’ve got everything saved and working, you can start customising it. To keep things clean, try to keep modifications to the following areas:

  • The constants at the top of wpt-vote-on-post.php
  • The function is_voting_on_posts_enabled()
  • The HTML voting snippet file wpt-vote-on-post/vote-snippet.php

If you want to take things a bit further, consider using the current votes to calculate an aggregate review score and implementing a review snippet schema. This will work great for your SEO in search results.

You might also look at hooking pre_get_posts and sorting by popularity . It would just be a simple meta_query based based on the numeric ‘up_vote_count’ post_meta key field.

Happy voting! 👍 👎 😎

Like This Tutorial?

loading

Let us know

2 thoughts on “Upvote and Downvote WordPress Posts”

  1. Avatar

    This awesome – I’m using this as boiler plate on a project. Before find this I ripped apart a few plugins and did some tutorials – but could quite get what I wanted. This has def helped. The addition I can think of to add is if a user comes back to the page or refreshes – and they still can not vote, the UPVOTE or DOWNVOTE button has a state indicating their previous vote. So if they upvoting something – it would still have an active up vote state or class.

Leave a Comment

Your email address will not be published.