cybrkyd

A PHP like button

 Sun, 24 Aug 2025 11:27 UTC

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.

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!

»
Tagged in:

Visitors: Loading...