How to Build a Dynamic Star Rating System with PHP JavaScript and MySQL

Updated: 26-Sep-2025 / Tags: PHP and AJAX / Views: 115

Introduction

Hello everyone.

In this tutorial, I'll walk you through creating a dynamic, interactive star rating system from scratch. Users will be able to hover over stars to see a potential rating, click to submit their vote, and see the average rating update instantly without a page reload. We'll use a combination of PHP and MySQL for the backend, and modern JavaScript (ES6+) for a seamless frontend experience.

This is what the final result will look like. Check the live demo. Click on the stars and see the results updating in real time. Once you vote a movie you can not vote the same movie again. That is because the votes are also stored in the browsers local storage. The application checks the local storage and if you have voted a movie it don't let you vote twice.

So enough with the introduction, check out the live demo and let's get started.

Live Demo

Prerequisites

  • A local development environment (like XAMPP, WAMP, or MAMP).
  • Basic knowledge of HTML, CSS, JavaScript, and PHP.
  • A MySQL database.

Project's folder

Let's create first all the project files inside your localhost server.

/--projects-folder
  |--index.php		
  |--db.php		
  |--rate.php		
  |--script.js		
  |--styles.css	
	

Step 1: The Backend - Database and PHP Setup

First, we need a place to store our movies and their ratings.

Database Schema

Create a database (e.g., ratings) and run the following SQL queries to create two tables: movies and ratings.

CREATE TABLE `movies` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
);

CREATE TABLE `ratings` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `movie_id` int(11) NOT NULL,
  `rating` tinyint(1) NOT NULL,
  PRIMARY KEY (`id`),
  FOREIGN KEY (`movie_id`) REFERENCES `movies`(`id`)
);

-- Let's add a couple of movies to get started
INSERT INTO `movies` (`title`) VALUES ('The Godfather'), ('The Shawshank Redemption');
	

Database Connection (db.php)

Inside the (db.php) file put in the following code. This keeps your credentials in one place. Also we will use PDO (PHP Data Object) to connect and query the database.

<?php
$host = 'localhost'; // or your db host
$dbname = 'ratings';
$user = 'your db user'; 
$pass = 'your db password'; 
$charset = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);
} catch (\PDOException $e) {
    throw new \PDOException($e->getMessage(), (int)$e->getCode());
}
?>		
	

Step 2: The Frontend - Displaying the Movies (index.php)

The index.php file is the heart of the application's user-facing side. It has two primary responsibilities:

  1. Backend Task (PHP): Fetching the initial movie and rating data from the database when the page is first loaded.
  2. Frontend Task (HTML): Displaying this data to the user in a structured way and linking the necessary CSS and JavaScript files to make the page functional and interactive.
<?php
require 'db.php';

// Fetch all movies and their rating data in one go
$stmt = $pdo->query("
    SELECT
        m.id,
        m.title,
        AVG(r.rating) as avg_rating,
        COUNT(r.id) as vote_count
    FROM
        movies m
    LEFT JOIN
        ratings r ON m.id = r.movie_id
    GROUP BY
        m.id, m.title
");
$movies_data = $stmt->fetchAll(PDO::FETCH_ASSOC);

?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PHP & JS Rating System</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>

    <div class="container">
        <h1>Rate Your Favorite Movie</h1>

        <?php foreach ($movies_data as $movie): ?>
            <div class="movie-item">
                <h3><?php echo htmlspecialchars($movie['title']); ?></h3>
                <div class="rating-stars" data-movie-id="<?php echo $movie['id']; ?>" data-average-rating="<?php echo $movie['avg_rating'] ?? 0; ?>">
                    <?php for ($i = 1; $i <= 5; $i++): ?>
                        <span class="star" data-value="<?php echo $i; ?>">☆</span>
                    <?php endfor; ?>
                </div>
                <div class="avg-rating">
                    <?php
                    $avg = round($movie['avg_rating'] ?? 0, 2);
                    $count = $movie['vote_count'] ?? 0;
                    echo "Average: {$avg} / 5 ({$count} votes)";
                    ?>
                </div>
            </div>
        <?php endforeach; ?>

    </div>

    <script src="script.js"></script>
</body>
</html>		
	

Now let's explain the index.php file

Part 1: The PHP Data Fetching Block

<?php
require 'db.php';
// Fetch all movies and their rating data in one go
$stmt = $pdo->query("...");
$movies_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>  
    
  • require 'db.php';: This line includes the database connection file (db.php), which creates the $pdo object. Without this, the script can't communicate with the database.
  • $pdo->query(...): This executes a single, powerful SQL query to get all the necessary data at once
  • SELECT m.id, m.title, AVG(r.rating) as avg_rating, COUNT(r.id) as vote_count: This selects the movie's ID and title, and then uses two aggregate functions:
    AVG(r.rating): Calculates the average rating for each movie.
    COUNT(r.id): Counts the total number of votes for each movie.
  • FROM movies m LEFT JOIN ratings r ON m.id = r.movie_id: This is a crucial step. It joins the movies table with the ratings table. The LEFT JOIN ensures that all movies are listed, even those that have zero ratings. For movies with no ratings, avg_rating and vote_count will be NULL.
  • GROUP BY m.id, m.title: This tells the database to group all the ratings by movie, so AVG() and COUNT() work on a per-movie basis.
  • $movies_data = $stmt->fetchAll(PDO::FETCH_ASSOC);: This fetches all the results from the query and stores them in the $movies_data variable as an associative array. This array will contain one entry for each movie, with keys like id, title, avg_rating, and vote_count.

Part 2: The HTML and Dynamic Content Display

This section builds the webpage that the user sees. It uses PHP within the HTML to dynamically generate content based on the data fetched in Part 1.

<?php foreach ($movies_data as $movie): ?>
    <div class="movie-item">
        <h3><?php echo htmlspecialchars($movie['title']); ?></h3>
        <div class="rating-stars" data-movie-id="<?php echo $movie['id']; ?>" data-average-rating="<?php echo $movie['avg_rating'] ?? 0; ?>">
            <?php for ($i = 1; $i <= 5; $i++): ?>
                <span class="star" data-value="<?php echo $i; ?>">☆</span>
            <?php endfor; ?>
        </div>
        <div class="avg-rating">
            ...
        </div>
    </div>
<?php endforeach; ?>
    
  • <?php foreach ($movies_data as $movie): ?>: This loop iterates through the $movies_data array. For each movie, it creates a div with the class movie-item.
  • <h3><?php echo htmlspecialchars($movie['title']); ?></h3>: This displays the movie title. htmlspecialchars() is a vital security function that prevents XSS attacks by converting special characters (like < and >) into HTML entities.
  • <div class="rating-stars" ...>: This container holds the stars for a single movie. It uses data-* attributes to embed important information directly into the HTML, which JavaScript can easily access:
    • data-movie-id="<?php echo $movie['id']; ?>": Stores the unique ID of the movie.
    • data-average-rating="<?php echo $movie['avg_rating'] ?? 0; ?>": Stores the pre-calculated average rating. The ?? 0 (null coalescing operator) ensures that if a movie has no ratings (avg_rating is NULL), it defaults to 0.
  • <?php for ($i = 1; $i <= 5; $i++): ?>: A simple loop that runs five times to create the five stars.
  • <span class="star" data-value="<?php echo $i; ?>">☆</span>: This creates each individual star. The data-value attribute is key for the JavaScript, as it tells the script whether this is the 1st, 2nd, 3rd, 4th, or 5th star.
  • <div class="avg-rating">...</div>: This block displays the human-readable average rating text, like "Average: 4.50 / 5 (12 votes)".

Step 3: The API Endpoint - Saving the Rating (rate.php)

The rate.php file acts as a specialized, backend-only API endpoint. Its sole purpose is to receive a rating from the user's browser, process it, and send back the updated results. It is never meant to be visited directly by a user; it only communicates with the script.js file.

This separation is a core principle of modern web development: the frontend (index.php and script.js) handles the user interface, while the backend (rate.php) handles the data processing and database interaction.

<?php
require 'db.php'; // Include the database connection
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);

if (!$data || !isset($data['movieId']) || !isset($data['rating'])) {
    http_response_code(400);
    echo json_encode(['status' => 'error', 'message' => 'Invalid data provided.']);
    exit;
}

$movieId = $data['movieId'];
$rating = $data['rating'];

try {
    $stmt = $pdo->prepare("INSERT INTO ratings (movie_id, rating) VALUES (?, ?)");
    $stmt->execute([$movieId, $rating]);

    // After saving, fetch the new average and count
    $stmt = $pdo->prepare("
        SELECT
            AVG(rating) as avg_rating,
            COUNT(id) as vote_count
        FROM
            ratings
        WHERE
            movie_id = ?
    ");
    $stmt->execute([$movieId]);
    $newRatingData = $stmt->fetch(PDO::FETCH_ASSOC);

    echo json_encode([
        'status' => 'success',
        'message' => 'Rating saved successfully.',
        'newAverage' => round($newRatingData['avg_rating'] ?? 0, 2),
        'newCount' => (int)($newRatingData['vote_count'] ?? 0)
    ]);
} catch (\PDOException $e) {
    http_response_code(500);
    // For debugging, you might want to log $e->getMessage() to a file
    echo json_encode(['status' => 'error', 'message' => 'Database error: Failed to save rating.']);
}

?>		
	

Let's break down the rate.php file

1. Read and Decode Incoming Data

header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
    
  • header('Content-Type: application/json');: This is a crucial line. It tells the client (our JavaScript fetch call) that the response from this script will be in JSON format. This allows the browser to automatically parse the response.
  • file_get_contents('php://input'): This reads the raw body of the HTTP request. When script.js sends data using JSON.stringify(), it arrives here as a raw text string.
  • json_decode(..., true): This function takes the JSON string and converts it into a PHP associative array. The true argument is important; without it, json_decode would create a generic object instead of an array. Using an array lets us access data with familiar syntax like $data['movieId'].

2. Validate the Input

if (!$data || !isset($data['movieId']) || !isset($data['rating'])) {
    http_response_code(400); // Bad Request
    echo json_encode(['success' => false, 'message' => 'Invalid input.']);
    exit;
}
    

This is a security and stability checkpoint. Before touching the database, we must ensure the data we received is valid.

  • It checks if the JSON was successfully decoded (!$data) and if the required keys (movieId, rating) are present.
  • If the input is invalid, it sets the HTTP status code to 400 Bad Request, sends back a JSON error message, and stops execution with exit;. This provides clear feedback to the frontend about what went wrong.

3. Insert the New Rating

$stmt = $pdo->prepare("INSERT INTO ratings (movie_id, rating) VALUES (?, ?)");
$stmt->execute([$movieId, $rating]);
    

This is where the data is saved.

  • $pdo->prepare(...): Using a prepared statement is the standard for security in modern PHP. It prevents SQL injection attacks by separating the SQL command from the data. The ? are placeholders.
  • $stmt->execute([$movieId, $rating]): This executes the prepared statement, safely binding the $movieId and $rating variables to the placeholders. A new row is added to the ratings table.

4. Recalculate the Average and Count

$stmt = $pdo->prepare("
    SELECT AVG(rating) as avg_rating, COUNT(id) as vote_count
    FROM ratings WHERE movie_id = ?
");
$stmt->execute([$movieId]);
$new_rating_data = $stmt->fetch(PDO::FETCH_ASSOC);
    

After saving the new rating, we immediately need to get the updated statistics for that specific movie.

  • This query calculates the new average rating (avg_rating) and total vote count (vote_count) for only the movie that was just rated (WHERE movie_id = ?).
    This is more efficient than re-querying all movies. We only ask for the data that has changed.
  • $stmt->fetch(PDO::FETCH_ASSOC) retrieves the single row of results.

5. Send the Response

echo json_encode([
    'success' => true,
    'avg_rating' => round($new_rating_data['avg_rating'] ?? 0, 2),
    'vote_count' => $new_rating_data['vote_count'] ?? 0
]);
    

This is the final step. The script sends a response back to the script.js file.

  • json_encode(...): This converts the PHP array into a JSON string, which is the standard format for API communication.
  • The response payload includes:
    • 'success' => true: A flag indicating the operation was successful.
    • 'avg_rating': The newly calculated average rating, rounded to two decimal places.
    • 'vote_count': The new total vote count.

This JSON response gives the JavaScript everything it needs to update the UI in real-time without requiring a page refresh.

Step 4: The Styling - Making it Look Good (style.css)

Good styling is key for an intuitive user experience. This CSS will handle the layout, the appearance of the stars, and the interactive hover and filled states.

body {
    font-family: sans-serif;
    background-color: #f4f4f4;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    margin: 0;
}

.container {
    background: #fff;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
    width: 500px;
}

.container h1 {
    text-align: center;
    margin-bottom: 2rem;
}

.movie-item {
    margin-bottom: 1.5rem;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid #eee;
}

.movie-item:last-child {
    border-bottom: none;
    margin-bottom: 0;
    padding-bottom: 0;
}

.avg-rating {
    font-size: 0.9rem;
    color: #666;
    margin-top: 0.5rem;
}

.star {
    font-size: 2rem;
    color: #ccc;
    cursor: pointer;
    transition: color 0.2s;
}

/* On hover, color all stars up to the hovered one */
.rating-stars:not(.rated):hover .star {
    color: #ffc107;
}

/* Then, un-color the stars after the hovered one */
.rating-stars:not(.rated) .star:hover ~ .star {
    color: #ccc;
}

/* Style for stars that are part of a submitted or average rating */
.rating-stars .star.filled {
    color: #ffc107;
}

/* When a rating has been submitted, disable hover effects and reduce opacity */
.rating-stars.rated {
    cursor: not-allowed;
}		
	

CSS Explanation:

  • body, .container, .movie-item: These rules set up the basic layout, creating a centered card on the page to hold our rating system.
  • .star: This defines the default appearance of a star: large, grey, and with a pointer cursor to indicate it's clickable.
  • .rating-stars:not(.rated):hover .star: This is a clever trick. When you hover over the rating-stars container (but only if it doesn't have the .rated class), it colors all the stars inside it yellow.
  • .rating-stars:not(.rated) .star:hover ~ .star: This rule works with the one above. It says: "When I hover over a specific star, select all of its following siblings (~ .star) and turn them back to grey." The result is that only the stars up to the one you're hovering over remain yellow.
  • .star.filled: This class is applied by JavaScript to permanently color the stars that represent the average or submitted rating.
  • .rating-stars.rated: We'll add this class with JavaScript after a user votes. It changes the cursor to not-allowed and, in combination with the :not(.rated) selectors, disables the hover effects.

Step 5: The Magic - Client-Side Interactivity (script.js)

The script.js file is the client-side "brain" of the rating system. It's responsible for making the page interactive and dynamic. Its primary jobs are:

  1. Handling User Interaction: It listens for mouse movements (hovering) and clicks on the stars.
  2. Visual Feedback: It provides immediate visual feedback by highlighting stars as the user interacts with them.
  3. Communicating with the Backend: When a user clicks to rate a movie, this script sends the rating data to rate.php without reloading the page (this is known as an AJAX call).
  4. Updating the UI: It receives the new average rating and vote count from rate.php and instantly updates the display on the page.

Here is a complete, functional version of script.js that works with the rest of the application, followed by a detailed breakdown.

document.addEventListener('DOMContentLoaded', function() {
    const ratingContainers = document.querySelectorAll('.rating-stars');
    const storageKey = 'movieRatings';

    // Function to update stars UI for a given container and rating
    function updateStars(container, rating) {
        const stars = container.querySelectorAll('.star');
        const ratingRounded = Math.round(rating); // Round to nearest whole star for display
        stars.forEach((s, index) => {
            if (index < ratingRounded) {
                s.classList.add('filled');
                s.innerHTML = '★';
            } else {
                s.classList.remove('filled');
                s.innerHTML = '☆';
            }
        });
    }

    // Load ratings from localStorage on page load
    const savedRatings = JSON.parse(localStorage.getItem(storageKey)) || {};

    ratingContainers.forEach(container => {
        const movieId = container.dataset.movieId;
        let averageRating = parseFloat(container.dataset.averageRating) || 0;

        // Always display the average rating initially
        updateStars(container, averageRating);

        // If a rating is saved in localStorage, just disable the container
        if (savedRatings[movieId]) {
            container.classList.add('rated');
        }

        // Add click event listeners only if not already rated
        if (!container.classList.contains('rated')) {
            const stars = container.querySelectorAll('.star');
            stars.forEach(star => {
                star.addEventListener('click', function() {
                    const value = parseInt(this.dataset.value);

                    // Update UI immediately with the user's vote for responsiveness
                    updateStars(container, value);

                    // Save to localStorage
                    savedRatings[movieId] = value;
                    localStorage.setItem(storageKey, JSON.stringify(savedRatings));

                    // Disable after rating
                    container.classList.add('rated');

                    // Send the rating to the server
                    fetch('rate.php', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({
                            movieId: movieId,
                            rating: value
                        })
                    })
                    .then(response => response.json())
                    .then(data => {
                        console.log('Server response:', data);
                        if (data.status === 'success') {
                            // Update the average rating text dynamically
                            const avgRatingElement = container.nextElementSibling;
                            if (avgRatingElement && avgRatingElement.classList.contains('avg-rating')) {
                                avgRatingElement.textContent = `Average: ${data.newAverage} / 5 (${data.newCount} votes)`;
                            }

                            // Update the stars to reflect the new average
                            updateStars(container, data.newAverage);

                            // Update the internal average rating for the mouseleave event
                            averageRating = data.newAverage;
                        }
                    })
                    .catch(error => {
                        console.error('Error:', error);
                        // Optional: Re-enable rating if server fails
                        container.classList.remove('rated');
                        delete savedRatings[movieId];
                        localStorage.setItem(storageKey, JSON.stringify(savedRatings));
                        // Restore previous state
                        updateStars(container, averageRating);
                    });
                });

                // When hovering out, restore the average rating display
                container.addEventListener('mouseleave', function() {
                    // Only restore if not yet rated by click
                    if (!container.classList.contains('rated')) {
                        updateStars(container, averageRating);
                    }
                });
            });
        }
    });
});
	

Breakdown of the Logic

1. Wait for the Page to Load

document.addEventListener('DOMContentLoaded', () => {
    // ... code runs after the page is fully loaded
});
    

This is the entry point. All the code inside this block will only run after the entire HTML document has been loaded and parsed by the browser. This is crucial because it ensures that all the elements we want to interact with (like .rating-stars) actually exist.

2. Initial Setup

const ratingContainers = document.querySelectorAll('.rating-stars');
const storageKey = 'movieRatings';        
    
  • ratingContainers: This line finds all the elements on the page with the class .rating-stars and stores them in a list. The script will loop through this list to make each movie's rating section interactive.
  • storageKey: This defines a consistent name ('movieRatings') to use as the key for saving and retrieving data from the browser's localStorage.

3. The updateStars Helper Function

function updateStars(container, rating) {
    const stars = container.querySelectorAll('.star');
    const ratingRounded = Math.round(rating); // Round to nearest whole star for display
    stars.forEach((s, index) => {
        if (index < ratingRounded) {
            s.classList.add('filled');
            s.innerHTML = '★'; 
        } else {
            s.classList.remove('filled');
            s.innerHTML = '☆';
        }
    });
}        
    

This is a reusable utility function that handles the visual state of the stars.

  • It takes a specific rating container (e.g., the one for "Inception") and a rating value (e.g., 3.7).
  • It rounds the rating to the nearest whole number (3.7 becomes 4).
  • It then loops through the 5 stars in that container. If a star's position (index) is less than the rounded rating, it gets the .filled class and is changed to a solid star (). Otherwise, it's styled as an empty star ().
  • The HTML code for the solid star is (★): &#9733;
  • The HTML code for the empty star (☆): &#9734;

4. Load Saved Ratings and Process Each Movie

const savedRatings = JSON.parse(localStorage.getItem(storageKey)) || {};

ratingContainers.forEach(container => {
    // ... logic for each movie
});        
    
  • savedRatings: The script checks localStorage for any previously saved ratings under the movieRatings key. JSON.parse() converts the stored string back into a JavaScript object. If nothing is found, it defaults to an empty object ({}).
  • forEach: The script now iterates through each movie's rating container to set it up.

5. Inside the Loop: Setting Up Each Movie's Rating

const movieId = container.dataset.movieId;
let averageRating = parseFloat(container.dataset.averageRating) || 0;

// Always display the average rating initially
updateStars(container, averageRating);

// If a rating is saved in localStorage, just disable the container
if (savedRatings[movieId]) {
    container.classList.add('rated');
}
    

Foe each movie:

  1. It reads the movieId and the initial averageRating from the data-* attributes you set in your PHP.
  2. It immediately calls updateStars() to display the current average rating when the page loads.
  3. It checks if the savedRatings object has an entry for the current movieId.
    • If yes, it means the user has already rated this movie in this browser. It adds the .rated class to the container, which (per your CSS) disables clicks and hover effects.
    • If no, the container remains interactive.

6. Adding the click Listener (The Core Action)

if (!container.classList.contains('rated')) {
    const stars = container.querySelectorAll('.star');
    stars.forEach(star => {
        star.addEventListener('click', function() {
             const value = parseInt(this.dataset.value);

            // Update UI immediately with the user's vote for responsiveness
            updateStars(container, value);

            // Save to localStorage
            savedRatings[movieId] = value;
            localStorage.setItem(storageKey, JSON.stringify(savedRatings));

            // Disable after rating
            container.classList.add('rated');
        });
    });
}
    

This if block ensures that click listeners are only added to movies that have not been rated yet.

When a user clicks a star:

  1. const value = parseInt(this.dataset.value);: It gets the rating value (1-5) from the clicked star's data-value attribute.
  2. updateStars(container, value);: It immediately updates the UI to show the user's selection. This provides instant feedback and makes the application feel responsive.
  3. savedRatings[movieId] = value; localStorage.setItem(...): It saves the user's vote to the savedRatings object and then writes the updated object back to localStorage.
  4. container.classList.add('rated');: It disables the rating container to prevent another vote.

7. Communicating with the Server (fetch)

After the UI is updated and the vote is saved locally, the script sends the data to the server.

fetch('rate.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ movieId: movieId, rating: value })
})
.then(response => response.json())
.then(data => {
    // ... handle success
})
.catch(error => {
    // ... handle failure
});
    
  • It makes a POST request to rate.php.
  • The body contains the movieId and rating value, formatted as a JSON string.
  • On Success (`.then`)

    if (data.status === 'success') {
        // Update the average rating text dynamically
        const avgRatingElement = container.nextElementSibling;
        if (avgRatingElement && avgRatingElement.classList.contains('avg-rating')) {
            avgRatingElement.textContent = `Average: ${data.newAverage} / 5 (${data.newCount} votes)`;
        }
    
        // Update the stars to reflect the new average
        updateStars(container, data.newAverage);
    
        // Update the internal average rating for the mouseleave event
        averageRating = data.newAverage;
    }                
                
    1. Finding the Text Element

      const avgRatingElement = container.nextElementSibling;
      • container: This variable holds the div with the class .rating-stars for the specific movie being rated.
      • .nextElementSibling: This is a standard DOM property. It looks for the very next HTML element that is a "sibling" to the container (i.e., at the same level in the HTML structure).
    2. Updating the Rating Text

      if (avgRatingElement && avgRatingElement.classList.contains('avg-rating')) {
      avgRatingElement.textContent = `Average: ${data.newAverage} / 5 (${data.newCount} votes)`;
      }
                          
      • `if (avgRatingElement && ...)`: This is a safety check. It first ensures that a next sibling element actually exists (avgRatingElement is not null).
      • `... && avgRatingElement.classList.contains('avg-rating')`: This second check makes sure the sibling element is the correct one by verifying it has the avg-rating class. This makes your code more robust in case the HTML structure changes later.
      • `avgRatingElement.textContent = ...`: If the checks pass, this line updates the text inside the avg-rating div.
      • `Average: ${data.newAverage} / 5 (${data.newCount} votes)`: This uses a JavaScript template literal to build the new string.
      • ${data.newAverage} and ${data.newCount} are placeholders that get filled with the fresh data sent back from your rate.php server. This instantly updates the displayed average and vote count on the page without requiring a refresh.
    3. Updating the Visual Stars

      // Update the stars to reflect the new average
      updateStars(container, data.newAverage);                        
                          

      When a user clicks on a star, the UI immediately fills the stars up to the one they clicked for instant feedback. However, the true new average rating (including their vote) might be a different number.

      This line calls the updateStars function again, passing it the true new average (data.newAverage) from the server. This corrects the visual star display to reflect the actual collective average, ensuring the UI is consistent with the data in the database.

    4. Updating the Internal State

      // Update the internal average rating for the mouseleave event
      averageRating = data.newAverage;                        
                          

      The averageRating variable was created at the beginning of the loop for each movie. It's used by the mouseleave event to restore the star display if a user hovers away without clicking.

      Since the user has now successfully voted, the old average rating is outdated. This line updates the averageRating variable with the new value from the server.

      Although the mouseleave event won't fire anymore for this movie (because it now has the .rated class), this is good practice for maintaining a correct internal state within the script.

  • On Failure (`.catch`)

    // Optional: Re-enable rating if server fails
    container.classList.remove('rated');
    delete savedRatings[movieId];
    localStorage.setItem(storageKey, JSON.stringify(savedRatings));
    // Restore previous state
    updateStars(container, averageRating);                
                
    1. Re-enabling the Rating Stars

      // Optional: Re-enable rating if server fails
      container.classList.remove('rated');                       
                         
      • When the user clicked a star, the script immediately added the .rated class to the star container (<div class="rating-stars">). This was an "optimistic update" to make the UI feel fast, assuming the server request would succeed. The .rated class disables further clicks.
      • Since the server request failed, this line removes the .rated class. This makes the stars clickable again, allowing the user to retry their rating.
    2. Removing the Rating from Local State

      delete savedRatings[movieId];
      localStorage.setItem(storageKey, JSON.stringify(savedRatings));
                          
      • Just like the UI, the script also optimistically saved the user's vote in two places:
        1. The savedRatings JavaScript object in memory.
        2. The browser's localStorage.
      • Since the database never received the rating, these local copies are now incorrect.
      • delete savedRatings[movieId]; removes the failed rating from the in-memory object.
      • localStorage.setItem(...) then saves the corrected savedRatings object (which no longer contains the failed vote) back to localStorage. This ensures that if the user reloads the page, it won't incorrectly show the movie as already rated.
    3. Restoring the Visual State

      // Restore previous state
      updateStars(container, averageRating);
                          
      • When the user clicked, the updateStars function was called to show their selected rating (e.g., 5 filled stars).
      • This line calls updateStars again, but this time it passes averageRating, which is the original average rating from before the user tried to vote.
      • This reverts the visual display of the stars back to what they were before the failed click, restoring the UI to its previous, correct state.

      In summary, this entire block is a rollback mechanism. It catches any server-side or network failure and carefully reverses all the client-side changes, effectively making it seem to the user as if their click never happened, allowing them to try again without confusion.

8. Restore the Average Rating Display

container.addEventListener('mouseleave', function() {
    // Only restore if not yet rated by click
    if (!container.classList.contains('rated')) {
        updateStars(container, averageRating);
    }
});
    
  • `container.addEventListener('mouseleave', ...)`: This attaches an event listener to the container (the <div class="rating-stars">). The mouseleave event fires the moment the user's mouse pointer moves outside the boundaries of this div.
  • The `if` statement is the most critical part. It checks if the container does NOT (!) have the class rated.
  • The rated class is only added after a user clicks a star to submit their rating.
  • Why is this check important?
    • Before Rating: If the user is just hovering, the container does not have the .rated class. The condition is true, and the code inside the if block will run.
    • After Rating: Once the user has clicked and rated, the .rated class is present. The condition !container.classList.contains('rated') becomes false. The code inside the if block is skipped. This is exactly what you want—after a user submits their vote, you don't want the stars to reset back to the average when their mouse moves away. Their submitted vote should remain visible.
  • It calls the updateStars function, passing it the averageRating that was loaded from the data-average-rating attribute when the page first loaded.
  • This effectively resets the star display, changing it from the temporary hover state back to showing the movie's actual average rating.

Summary

This article provides a comprehensive guide to building a dynamic star rating system using a full-stack approach. The tutorial walks through creating an interactive and user-friendly feature where users can rate items (in this case, movies) and see the results updated in real-time without a page refresh.

The key components and steps covered are:

Backend (PHP & MySQL)

Database: Setting up a MySQL database with two tables: movies to store the items and ratings to store each individual vote.

PHP Scripts:

index.php: Fetches all movies and their current average rating and vote count from the database to display on page load.

rate.php: Acts as an API endpoint. It securely receives a new rating via a JavaScript fetch request, saves it to the database, recalculates the new average rating and vote count, and returns this data as a JSON response.

Frontend (HTML, CSS & JavaScript):

HTML: Structures the page, displaying each movie with a set of five empty stars () and the initial average rating text.

CSS: Styles the system for a clean look and feel. It includes clever CSS selectors to create an intuitive hover effect where stars light up as the user moves their mouse over them.

JavaScript (script.js): This is the core of the interactivity. The script handles:

Hover Effects: Temporarily filling stars on mouseover to give visual feedback.

Click to Rate: Capturing the user's selected rating when they click a star.

AJAX with fetch(): Sending the new rating to the rate.php script asynchronously.

UI Updates: Instantly updating the average rating text and permanently filling the stars based on the successful response from the server.

Preventing Re-votes: Using the browser's localStorage to remember which movies the user has already rated, disabling the functionality to prevent duplicate submissions.

By combining these technologies, the tutorial demonstrates how to create a seamless, modern, and professional rating feature from scratch.

Source code

If you feel like avoiding all the copying and pasting, you can buy me a coffee and get the source code in a zip file. Get the source code

Buy me a coffee

If you like to say thanks, you can buy me a coffee.

Buy me a coffee with paypal

Comment section

You can leave a comment, it will help me a lot.

Or you can just say hi. 😉