A PHP like button
Hot on the heels of my PHP hit counter, I now have a “like” button. It is based on the codebase of the hit counter, using the same principle of tracking the likes by hashed IP addresses over a period of 90 days.
I spotted a tiny “like” system on Bear Blog; they call theirs Toast this post. I loved it, so I set out to replicate how it works. I’m not into website comments, but I do have a place in my cold-as-stone heart for an appreciative like or two!
The concept translated well into my existing code: 1. The like button is clicked. 2. The liking IP address is hashed and stored in a JSON file. 3. Total number of likes is displayed on-page. 4. Persistent storage of hashed IP addresses over a 90-day period to prevent repeated likes in that time window.
My only gripe with this is how it is not “tiny” at all! Especially the Hugo snippet.
The logic
This PHP script lets people “like” posts, remembers them anonymously using a hash of their IP address, prevents multiple likes from the same person within 90 days, and returns the like count as JSON.
- I only want
/post/slug
to have the like button. - The
$data_file
variable specifies the path to the likes count storage file,/data/likes.json
. $salt
: A secret word (salty_salt) used to scramble the liking IP addresses for privacy.
Remember to change the permissions of /data
to 750 (rwxr-x---) and likes.php
and likes.json
to 640 (rw-r-----).
PHP like button
<?php
$data_file = __DIR__.'/data/likes.json';
$salt = "salty_salt";
// Get path from either GET or POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$path = $_POST['path'] ?? '';
} else {
$path = $_GET['path'] ?? '';
}
// Validate path (only allow /post/slug/ format)
if (!preg_match('~^/post/[a-z0-9\-]+/$~i', $path)) {
header('Content-Type: application/json');
echo json_encode(['error' => 'invalid_path', 'received_path' => $path]);
exit;
}
// Generate anonymous IP hash (8 chars)
$ip_hash = substr(hash('sha256', $_SERVER['REMOTE_ADDR'] . $salt), 0, 8);
$current_time = time();
$retention_period = 7776000; // 90 days
$lock = fopen($data_file, 'c+');
if (flock($lock, LOCK_EX)) {
$data = file_exists($data_file) ? json_decode(file_get_contents($data_file), true) : [];
// Initialize path data if missing
if (!isset($data[$path])) {
$data[$path] = ['total' => 0, 'hashes' => []];
}
$already_liked = false;
$now = time();
// Check for existing like and clean up old entries
$valid_hashes = [];
foreach ($data[$path]['hashes'] as $hash_data) {
// Keep only 90-day worth of IP addresses
if ($now - $hash_data['timestamp'] <= $retention_period) {
$valid_hashes[] = $hash_data;
// Check if current IP has already liked
if ($hash_data['hash'] === $ip_hash) {
$already_liked = true;
}
}
}
// Update the hashes array with only valid entries
$data[$path]['hashes'] = $valid_hashes;
// Update total count based on remaining valid hashes
$data[$path]['total'] = count($valid_hashes);
// Process like if not already liked
if (!$already_liked && $_SERVER['REQUEST_METHOD'] === 'POST') {
$data[$path]['total']++;
$data[$path]['hashes'][] = [
'hash' => $ip_hash,
'timestamp' => $now
];
}
// Save data
file_put_contents($data_file, json_encode($data));
flock($lock, LOCK_UN);
} else {
// Could not acquire lock
header('Content-Type: application/json');
echo json_encode(['error' => 'Could not acquire file lock']);
fclose($lock);
exit;
}
fclose($lock);
// Return appropriate response
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
echo json_encode([
'success' => !$already_liked,
'already_liked' => $already_liked,
'count' => $data[$path]['total'],
'ip_hash' => $ip_hash
]);
} else {
echo json_encode([
'already_liked' => $already_liked,
'count' => $data[$path]['total'],
'ip_hash' => $ip_hash
]);
}
?>
The CSS
.like-container{
margin:0.7rem 0;
padding-left:50px;
display:flex;
justify-content:flex-start;
}
.like-button{
display:inline-flex;
align-items:center;
cursor:pointer;
gap:0.5rem;
padding:0;
background:transparent;
border:none;
}
.heart-icon{
width:18px;
height:18px;
color:#666;
}
.heart-icon.filled{
display:none;
color:#ff6b6b;
}
.like-button.liked .heart-icon.unfilled{
display:none;
}
.like-button.liked .heart-icon.filled{
display:block;
}
.like-count{
font-size:1rem;
color:#666;
}
.like-button.liked .like-count{
color:#ff6b6b;
}
The Hugo snippet
This is in my /layouts/_default/single.html
file.
{{ if eq .Type "post" }}
<div class="like-container">
{{ if .Site.IsServer }}
[Likes]
{{ else }}
<button id="like-button" class="like-button checking" aria-label="Like this post" title="Like this post">
<svg class="heart-icon unfilled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<svg class="heart-icon filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span class="like-count" id="like-count">
Loading...
</span>
</button>
{{ end }}
</div>
{{ if not .Site.IsServer }}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.location.pathname.includes('/post/')) {
const likeButton = document.getElementById('like-button');
const likeCount = document.getElementById('like-count');
// Check current like status
fetch(`/likes.php?path=${encodeURIComponent(window.location.pathname)}`)
.then(response => response.json())
.then(data => {
likeButton.classList.remove('checking');
if (data.already_liked) {
likeButton.classList.add('liked');
likeButton.disabled = true;
likeButton.title = "Liked";
}
likeCount.textContent = data.count || '0';
})
.catch(error => {
likeButton.classList.remove('checking');
likeCount.textContent = 'Error';
console.error('Error checking like status:', error);
});
// Handle like button click
likeButton.addEventListener('click', function() {
if (this.classList.contains('liked') || this.classList.contains('checking')) {
return;
}
this.classList.add('checking');
const formData = new FormData();
formData.append('path', window.location.pathname);
fetch('/likes.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
this.classList.remove('checking');
if (data.success) {
this.classList.add('liked');
this.disabled = true;
this.title = "Liked";
likeCount.textContent = data.count;
} else if (data.already_liked) {
this.classList.add('liked');
this.disabled = true;
this.title = "Liked";
likeCount.textContent = data.count;
} else if (data.error) {
alert('Error: ' + data.error);
}
})
.catch(error => {
this.classList.remove('checking');
console.error('Error liking post:', error);
alert('Sorry, there was a processing error. Please try again.');
});
});
}
});
</script>
{{ end }}{{ end }}
Don’t forget to like and subscribe, and smash that like button!