How to Build a Simple Reviews and Rating System with Livewire and Jetstream?

Created January 18, 2021

Introduction

Laravel Livewire, created by Caleb Porzio, is a full-stack framework that allows you to add reactivity to your Laravel applications.

If you are just getting started with Laravel Livewire, make sure to check out this introduction to Livewire tutorial.

Laravel Jetstream is a new application scaffolding for Laravel. Laravel Jetstream replaces the legacy Laravel authentication UI available for previous Laravel versions.

In this tutorial, I will show you how to build simple reviews and rating system for your Laravel Jetstream project where registered users will be able to rate and review a specific product 1 time only! Quick demo:

Simple Laravel Livewire Review System

Prerequisites

To get started, all that you need is a Laravel application.

If you don't have one, you can follow the steps here on how to install Laravel on DigitalOcean with 1-Click.

If you are new to DigitalOcean, you can use my referral link to get a free $100 credit so that you can spin up your own servers for free:

Free $100 DigitalOcean credit

As we would want to limit the access to the reviews and rating functionality to registered users only, you would need to have a user authentication system in place. In this tutorial, we will use Laravel Jetstream, but it will work with Laravel UI and Laravel Breeze.

For more information on how to get started, make sure to check out this tutorial here: What is Laravel Jetstream and how to get started?

Once you have your Laravel Jetstream project ready, let's go ahead and prepare our database migrations!

Creating a Product Model (Optional)

As an example, let's create a Products table and a model which we will use to add reviews and ratings to.

If you already have a model that you would like to use, you don't have to follow the steps here.

To do so, run the following command:

php artisan make:model Product -m

Output:

Model created successfully.
Created Migration: 2021_01_19_082007_create_products_table

To keep things simple, let's limit the product table to only a title and a description. So with your favorite text editor open the migration file and update the Schema::create method to:

        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });

Then let's create a DB seeder to add a few products in our database, which we will later on reviews/rate and comment to:

php artisan make:seeder ProductSeeder

Then let's create a dummy product by updating your ProductSeeder.php file to:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class ProductSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('products')->insert([
            'title' => 'My Product title',
            'description' => 'An awesome product',
        ]);
    }
}

After that, enable your seeder by adding the following in the database/seeders/DatabaseSeeder.php file:

    public function run()
    {
        $this->call([
            ProductSeeder::class,
        ]);

    }

Finally, seed the database:

php artisan db:seed

This will basically create a sample product that we can work with.

Creating the Rating Model

Once you have your product model ready, let's go ahead and create our Rating model and table.

php artisan make:model Rating  -m

Update your your Rating to:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;


class Rating extends Model
{
    /**
     * Attributes to guard against mass-assignment.
     *
     * @var array
     */
    protected $guarded = [];

    protected $fillable = [
        'comment'
    ];

    public function user()
    {
        return $this->belongsTo('App\Models\User');
    }

    public function product()
    {
        return $this->belongsTo('App\Models\Product');
    }
}

We are basically adding 2 relations so that a specific rating/reviews would belong to a user and a product.

After that, make sure to add the following method to your Product model too:

    public function ratings()
    {
        return $this->hasMany('App\Models\Rating');
    }

That way, one product could have many ratings.

Preparing the Ratings table

Once you are ready with your models, let's go ahead and add the following to your ratings migration:

    public function up()
    {
        Schema::create('ratings', function (Blueprint $table) {
            $table->id();
            $table->integer('user_id');
            $table->integer('product_id');
            $table->integer('rating');
            $table->text('comment');
            $table->integer('status');
            $table->timestamps();
        });
    }

We will have the following fields:

With that in place, run the migrations:

php artisan migrate

Next, let's add the route and the controller for the product view. You can skip this step in case that you already have a route and a view.

Preparing the Product Controller and Route and View (Optional)

To keep things simple, let's create only a page which will show a specific product by ID.

Note: if you already have a model that you are working with, you can skip this step, it is only for demo purposes

First, create a controller:

php artisan make:controller ProductsController

In the controller add a method that takes the product id as an argument:

public function show($id)
{
    $product = \App\Models\Product::findOrFail($id);
    return view('product', compact('product'));
}

Then create the product view at resources/views/product.blade.php and add the following sample content:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Rating system</title>

    <!-- Fonts -->
    <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">

    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
    @livewireStyles

</head>

<body class="antialiased">
    <div
        class="relative flex justify-center min-h-screen bg-gray-100 items-top dark:bg-gray-700 sm:items-center sm:pt-0">

        <div class="mt-8 overflow-hidden bg-white shadow dark:bg-gray-200 sm:rounded-lg">
            <div class="fixed inset-0 z-10 overflow-y-auto bg-white">
                <div class="flex items-center justify-center min-h-screen text-center">
                    <div class="inline-block px-2 py-6 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg w-full"
                        role="dialog" aria-modal="true" aria-labelledby="modal-headline">
                        <div class="pb-2 bg-white">
                            <div class="flex-col items-center sm:flex">
                                <div
                                    class="flex items-center justify-center flex-shrink-0 w-12 h-12 p-4 mx-auto bg-red-100 rounded-full sm:mx-0 sm:h-16 sm:w-16">
                                    <svg class="w-full h-full text-red-600" viewBox="0 0 24 24" fill="none"
                                        stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                        stroke-linejoin="round">
                                        <line x1="19" y1="5" x2="5" y2="19"></line>
                                        <circle cx="6.5" cy="6.5" r="2.5"></circle>
                                        <circle cx="17.5" cy="17.5" r="2.5"></circle>
                                    </svg>
                                </div>
                                <div class="mt-3 mb-1 text-center sm:ml-4 sm:text-left">
                                    <h3 class="pt-1 text-3xl font-black leading-6 text-gray-900" id="modal-headline">
                                        {{ $product->title }}
                                    </h3>
                                </div>
                            </div>
                        </div>
                        <div class="w-full text-base text-center text-gray-600">
                            {{ $product->description }}
                        </div>

                        <div
                            class="justify-center w-full px-4 mt-2 font-sans text-xs leading-6 text-center text-gray-500">
                            <a href="#_">Terms and conditions apply</a>
                        </div>
                    </div>
                </div>
            </div>
        </div>

    </div>
    </div>
    @livewireScripts

</body>

</html>

I've got the template from here.

Note: to keep things simple, we are not using a master layout so that you could have a better overview of the demo product page.

And finally, add a get route to your routes/web.php file:

use App\Http\Controllers\ProductsController;

Route::get('/product/{id}', [ProductsController::class, 'show']);

Then if you visit your website /products/1 you will see your first product.

Adding new Livewire component

With the product model in place, let's go ahead and add the Livewire component!

To create the new Livewire component, run the following command:

 php artisan livewire:make product-ratings

Output:


CLASS: app/Http/Livewire/ProductRatings.php
VIEW:  resources/views/livewire/product-ratings.blade.php

First, let's go ahead and add our view which will contain the form for our ratings and comments. Open the resources/views/livewire/product-ratings.blade.php file and add the following content:

<div>
    <section class="w-full px-8 pt-4 pb-10 xl:px-8">
        <div class="max-w-5xl mx-auto">
            <div class="flex flex-col items-center md:flex-row">

                <div class="w-full mt-16 md:mt-0">
                    <div class="relative z-10 h-auto p-4 py-10 overflow-hidden bg-white border-b-2 border-gray-300 rounded-lg shadow-2xl px-7">
                        @auth
                            <div class="w-full space-y-5">
                                <p class="font-medium text-blue-500 uppercase">
                                    Rate this product
                                </p>
                            </div>
                            @if (session()->has('message'))
                                <p class="text-xl text-gray-600 md:pr-16">
                                    {{ session('message') }}
                                </p>
                            @endif
                            @if($hideForm != true)
                                <form wire:submit.prevent="rate()">
                                    <div class="block max-w-3xl px-1 py-2 mx-auto">
                                        <div class="flex space-x-1 rating">
                                            <label for="star1">
                                                <input hidden wire:model="rating" type="radio" id="star1" name="rating" value="1" />
                                                <svg class="cursor-pointer block w-8 h-8 @if($rating >= 1 ) text-indigo-500 @else text-grey @endif " fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
                                            </label>
                                            <label for="star2">
                                                <input hidden wire:model="rating" type="radio" id="star2" name="rating" value="2" />
                                                <svg class="cursor-pointer block w-8 h-8 @if($rating >= 2 ) text-indigo-500 @else text-grey @endif " fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
                                            </label>
                                            <label for="star3">
                                                <input hidden wire:model="rating" type="radio" id="star3" name="rating" value="3" />
                                                <svg class="cursor-pointer block w-8 h-8 @if($rating >= 3 ) text-indigo-500 @else text-grey @endif " fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
                                            </label>
                                            <label for="star4">
                                                <input hidden wire:model="rating" type="radio" id="star4" name="rating" value="4" />
                                                <svg class="cursor-pointer block w-8 h-8 @if($rating >= 4 ) text-indigo-500 @else text-grey @endif " fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
                                            </label>
                                            <label for="star5">
                                                <input hidden wire:model="rating" type="radio" id="star5" name="rating" value="5" />
                                                <svg class="cursor-pointer block w-8 h-8 @if($rating >= 5 ) text-indigo-500 @else text-grey @endif " fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z"/></svg>
                                            </label>
                                        </div>
                                        <div class="my-5">
                                            @error('comment')
                                                <p class="mt-1 text-red-500">{{ $message }}</p>
                                            @enderror
                                            <textarea wire:model.lazy="comment" name="description" class="block w-full px-4 py-3 border border-2 rounded-lg focus:border-blue-500 focus:outline-none" placeholder="Comment.."></textarea>
                                        </div>
                                    </div>
                                    <div class="block">
                                        <button type="submit" class="w-full px-3 py-4 font-medium text-white bg-blue-600 rounded-lg">Rate</button>
                                        @auth
                                            @if($currentId)
                                                <button type="submit" class="w-full px-3 py-4 mt-4 font-medium text-white bg-red-400 rounded-lg" wire:click.prevent="delete({{ $currentId }})" class="text-sm cursor-pointer">Delete</button>
                                            @endif
                                        @endauth
                                    </div>
                                </form>
                            @endif
                        @else
                            <div>
                                <div class="mb-8 text-center text-gray-600">
                                    You need to login in order to be able to rate the product!
                                </div>
                                <a href="/register"
                                    class="block px-5 py-2 mx-auto font-medium text-center text-gray-600 bg-white border rounded-lg shadow-sm focus:outline-none hover:bg-gray-100" 
                                >Register</a>
                            </div>
                        @endauth
                    </div>
                </div>
    
            </div>
        </div>
    </section>
    <section class="relative block pt-20 pb-24 overflow-hidden text-left bg-white">
        <div class="w-full px-20 mx-auto text-left md:px-10 max-w-7xl xl:px-16">
            <div class="box-border flex flex-col flex-wrap justify-center -mx-4 text-indigo-900">
                <div class="relative w-full mb-12 leading-6 text-left xl:flex-grow-0 xl:flex-shrink-0">
                    <h2 class="box-border mx-0 mt-0 font-sans text-4xl font-bold text-center text-indigo-900">
                        Ratings
                    </h2>
                </div>
            </div>
            <div class="box-border flex grid flex-wrap justify-center gap-10 -mx-4 text-center text-indigo-900 lg:gap-16 lg:justify-start lg:text-left">
                @forelse ($comments as $comment)
                    <div class="flex col-span-1">
                        <div class="relative flex-shrink-0 w-20 h-20 text-left">
                            <a href="{{ '@' . $comment->user->name }}">
                            </a>
                        </div>
                        <div class="relative px-4 mb-16 leading-6 text-left">
                            <div class="box-border text-lg font-medium text-gray-600">
                                {{ $comment->comment }}
                            </div>
                            <div class="box-border mt-5 text-lg font-semibold text-indigo-900 uppercase">
                                Rating: <strong>{{ $comment->rating }}</strong> ⭐
                                @auth
                                    @if(auth()->user()->id == $comment->user_id || auth()->user()->role->name == 'admin' ))
                                        - <a wire:click.prevent="delete({{ $comment->id }})" class="text-sm cursor-pointer">Delete</a>
                                    @endif
                                @endauth
                            </div>
                            <div class="box-border text-left text-gray-700" style="quotes: auto;">
                                <a href="{{ '@' . $comment->user->username }}">
                                    {{  $comment->user->name }}
                                </a>
                            </div>
                        </div>
                    </div>
                @empty
                <div class="flex col-span-1">
                    <div class="relative px-4 mb-16 leading-6 text-left">
                        <div class="box-border text-lg font-medium text-gray-600">
                            No ratings
                        </div>
                    </div>
                </div>
                @endforelse

            </div>
    </section>
    
</div>

Then to include this to your products view, add the following to the resources/views/product.blade.php:

        @livewire('product-ratings', ['product' => $product], key($product->id))

After that, let's go ahead and add the Livewire logic.

Adding the Livewire logic

Open the app/Http/Livewire/ProductRatings.php and add the following content:

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use App\Models\Rating;

class ProductRatings extends Component
{
    public $rating;
    public $comment;
    public $currentId;
    public $product;
    public $hideForm;

    protected $rules = [
        'rating' => ['required', 'in:1,2,3,4,5'],
        'comment' => 'required',

    ];

    public function render()
    {
        $comments = Rating::where('product_id', $this->product->id)->where('status', 1)->with('user')->get();
        return view('livewire.product-ratings', compact('comments'));
    }

    public function mount()
    {
        if(auth()->user()){
            $rating = Rating::where('user_id', auth()->user()->id)->where('product_id', $this->product->id)->first();
            if (!empty($rating)) {
                $this->rating  = $rating->rating;
                $this->comment = $rating->comment;
                $this->currentId = $rating->id;
            }
        }
        return view('livewire.product-ratings');
    }

    public function delete($id)
    {
        $rating = Rating::where('id', $id)->first();
        if ($rating && ($rating->user_id == auth()->user()->id)) {
            $rating->delete();
        }
        if ($this->currentId) {
            $this->currentId = '';
            $this->rating  = '';
            $this->comment = '';
        }
    }

    public function rate()
    {
        $rating = Rating::where('user_id', auth()->user()->id)->where('product_id', $this->product->id)->first();
        $this->validate();
        if (!empty($rating)) {
            $rating->user_id = auth()->user()->id;
            $rating->product_id = $this->product->id;
            $rating->rating = $this->rating;
            $rating->comment = $this->comment;
            $rating->status = 1;
            try {
                $rating->update();
            } catch (\Throwable $th) {
                throw $th;
            }
            session()->flash('message', 'Success!');
        } else {
            $rating = new Rating;
            $rating->user_id = auth()->user()->id;
            $rating->product_id = $this->product->id;
            $rating->rating = $this->rating;
            $rating->comment = $this->comment;
            $rating->status = 1;
            try {
                $rating->save();
            } catch (\Throwable $th) {
                throw $th;
            }
            $this->hideForm = true;
        }
    }
}

Testing

Once you've added all of the components, when visiting the /product/1 URL you will see the following page:

Laravel Livewire rating system

So to get to the rating component, you would need to login first.

Once you've logged in, you will see the following screen:

Simple Livewire rating system

Note that if you are unable to click on any of the links, you might have to publish the Livewire assets:

php artisan vendor:publish --force --tag=livewire:assets

Simple Demo:

Simple Laravel Livewire Review System

Conclusion

This is pretty much it! Now you have a simple rating and comments system for your Laravel Jetstream project!

Note that this will also work with Laravel Breeze, but you will need to install Laravel Livewire additionally.

You can find the source here:

Laravel Livewire Simple Rating and Review System

Feel free to contribute improvements and suggestions!

I hope that this helps!