← Open Source

Rating

Laravel

A complete rating and review system for Laravel — ratable models, averages and moderation.

15,621 downloads 86 stars Latest version v2.1.1

Install

composer require ghanem/rating

Laravel Rating

Latest Stable Version License Total Downloads

Laravel Rating

Rating system for Laravel 8, 9, 10, 11, 12 & 13.

Using Filament? See ghanem/rating-filament — a star input field, a sortable average-rating table column, an infolist entry and a review moderation relation manager for Filament 4 & 5.

Installation

composer require ghanem/rating

Upgrading from V12.0? That tag was a mis-tag: it read as "Laravel 12 support" but registered on Packagist as major version 12, so it outranked every 2.x release. It has been removed. If your composer.json says "ghanem/rating": "^12.0", change it to "^2.1" and run composer update ghanem/rating. No code changes are needed — v2.1.0 is the same line, plus Laravel 13 support and the migration publishing fixes.

The package uses Laravel's auto-discovery, so no need to manually register the service provider.

Getting started

Publish and run the migration:

php artisan vendor:publish --provider="Ghanem\Rating\RatingServiceProvider"
php artisan migrate

Optionally publish the config file:

php artisan vendor:publish --tag=rating-config

Usage

Setup a Model

Add the Ratingable trait to any model you want to be ratable:

use Ghanem\Rating\Traits\Ratingable;

class Post extends Model
{
    use Ratingable;
}

Ratingable is a trait, not an interface. Put it in use inside the class body — never in implements. class Post extends Model implements Ratingable fails with "cannot implement Ratingable - it is not an interface".

Add the CanRate trait to the author model:

use Ghanem\Rating\Traits\CanRate;

class User extends Model
{
    use CanRate;
}

Create a rating

// From the ratable model
$rating = $post->rating(['rating' => 5], $user);

// From the author model
$rating = $user->rate($post, ['rating' => 5]);

Create or update a unique rating

Only one rating per author per model:

$rating = $post->ratingUnique(['rating' => 5], $user);

// Or from the author
$rating = $user->rateUnique($post, ['rating' => 5]);

Update a rating

$rating = $post->updateRating($ratingId, ['rating' => 3]);

Delete a rating

$post->deleteRating($ratingId);

Rating with review body

$post->rating([
    'rating' => 5,
    'body' => 'Great article!',
], $user);

Scoped ratings (rate different aspects)

$restaurant->rating(['rating' => 5, 'type' => 'food'], $user);
$restaurant->rating(['rating' => 3, 'type' => 'service'], $user);

$restaurant->avgRating('food');    // 5.0
$restaurant->avgRating('service'); // 3.0
$restaurant->avgRating();          // 4.0 (all types)

Weighted ratings

$post->rating(['rating' => 5, 'weight' => 2], $verifiedUser);
$post->rating(['rating' => 3, 'weight' => 1], $regularUser);

$post->weightedAvgRating(); // 4.33

Aggregates

All aggregate methods accept an optional $type parameter for scoped ratings:

$post->avgRating()          // average rating
$post->sumRating()          // sum of all ratings
$post->countRatings()       // total count
$post->countPositive()      // count where rating > 0
$post->countNegative()      // count where rating < 0
$post->ratingPercent()      // percentage (default max: 5)
$post->ratingPercent(10)    // percentage with custom max
$post->weightedAvgRating()  // weighted average

All available as attributes too:

$post->avgRating
$post->sumRating
$post->countRatings
$post->countPositive
$post->countNegative
$post->ratingPercent
$post->weightedAvgRating

Author queries (CanRate)

$user->hasRated($post);          // bool
$user->getRating($post);         // Rating|null
$user->averageGivenRating();     // float
$user->totalGivenRatings();      // int
$user->ratings;                  // all ratings given

Check if rated

$post->isRatedBy($user);            // bool
$post->isRatedBy($user, 'food');    // bool (scoped)

Query scopes

// Eager load rating aggregates
Post::withAvgRating()->get();
Post::withSumRating()->get();
Post::withCountRatings()->get();

// Order by ratings
Post::orderByAvgRating()->get();        // desc by default
Post::orderByAvgRating('asc')->get();
Post::orderBySumRating()->get();
Post::orderByCountRatings()->get();

// Filter by minimum rating
Post::minAvgRating(3.5)->get();
Post::minSumRating(10)->get();

// Scoped by type
Post::withAvgRating('food')->get();
Post::orderByAvgRating('desc', 'food')->get();

Displaying ratings in Blade

The package is storage-only — it ships no views or assets, so you stay in control of your markup. Here is a complete 5-star setup with no JavaScript and no front-end dependencies.

1. Read-only stars

ratingPercent() already returns the average as a percentage of the maximum, which is exactly what a CSS clip needs. Fractional averages (3.7 stars) render correctly with no extra work.

{{-- resources/views/components/stars.blade.php --}}
@props(['percent' => 0])

<span {{ $attributes->merge(['class' => 'stars']) }} style="--rating: {{ $percent }}%">
    ★★★★★
</span>
.stars {
    position: relative;
    display: inline-block;
    color: #d1d5db;
    letter-spacing: 2px;
    white-space: nowrap;
}

.stars::before {
    content: '★★★★★';
    position: absolute;
    top: 0;
    left: 0;
    width: var(--rating);
    overflow: hidden;
    color: #f59e0b;
    letter-spacing: 2px;
}
<x-stars :percent="$post->ratingPercent()" />
<span>{{ number_format($post->avgRating(), 1) }} out of 5 ({{ $post->countRatings() }})</span>

For a 10-point scale, pass the max: $post->ratingPercent(10).

2. An interactive rating form

Radio inputs in reverse order, so the CSS sibling selector can highlight the hovered star and every star before it. Accessible and keyboard-operable, because it is a real radio group.

<form method="POST" action="{{ route('posts.rate', $post) }}">
    @csrf

    <fieldset class="rating-input">
        <legend>Your rating</legend>

        @foreach (range(5, 1) as $value)
            <input
                type="radio"
                id="star-{{ $value }}"
                name="rating"
                value="{{ $value }}"
                {{ auth()->user()?->getRating($post)?->rating == $value ? 'checked' : '' }}
            >
            <label for="star-{{ $value }}" title="{{ $value }} stars">★</label>
        @endforeach
    </fieldset>

    <textarea name="body" placeholder="Leave a review (optional)"></textarea>

    <button type="submit">Submit</button>
</form>
.rating-input {
    display: inline-flex;
    flex-direction: row-reverse; /* lets `~` reach the stars to the left */
    border: 0;
}

.rating-input input {
    position: absolute;
    opacity: 0;      /* hidden from sight, still focusable */
}

.rating-input label {
    cursor: pointer;
    font-size: 1.75rem;
    color: #d1d5db;
}

.rating-input input:checked ~ label,
.rating-input label:hover,
.rating-input label:hover ~ label {
    color: #f59e0b;
}

.rating-input input:focus-visible + label {
    outline: 2px solid #2563eb;
}

3. Route and controller

// routes/web.php
Route::post('posts/{post}/rate', [RatingController::class, 'store'])
    ->middleware('auth')
    ->name('posts.rate');
class RatingController extends Controller
{
    public function store(Request $request, Post $post)
    {
        $data = $request->validate([
            'rating' => ['required', 'integer', 'min:1', 'max:5'],
            'body' => ['nullable', 'string', 'max:2000'],
        ]);

        // rateUnique() updates the user's existing rating instead of adding a second one
        $request->user()->rateUnique($post, $data);

        return back()->with('status', 'Thanks for rating!');
    }
}

Validate in the request as well as configuring config/rating.php. The config bounds throw InvalidRatingException, which surfaces as a 500; request validation gives the user a normal field error instead.

4. Listing many rated models

Calling $post->avgRating() inside a loop runs one aggregate query per row. Load the aggregates with the query instead:

$posts = Post::withAvgRating()->withCountRatings()->paginate();
@foreach ($posts as $post)
    {{-- read the eager-loaded aliases, not the accessors --}}
    <x-stars :percent="($post->ratings_avg_rating / 5) * 100" />
    <span>{{ $post->ratings_count }} ratings</span>
@endforeach

withAvgRating() selects a ratings_avg_rating alias and withCountRatings() selects ratings_count. Both are plain columns on the result, so sorting and filtering happen in SQL — see Query scopes.

Validation

Configure rating bounds in config/rating.php:

return [
    'min' => 1,
    'max' => 5,
    'allow_negative' => false,
];

Invalid ratings throw Ghanem\Rating\Exceptions\InvalidRatingException.

Events

The package fires events on rating lifecycle:

  • Ghanem\Rating\Events\RatingCreated
  • Ghanem\Rating\Events\RatingUpdated
  • Ghanem\Rating\Events\RatingDeleted

Each event has a public $rating property with the Rating model.

Filament admin panel

ghanem/rating-filament (Packagist) adds Filament 4 & 5 components on top of this package:

composer require ghanem/rating-filament
Component Purpose
RatingInput Clickable star picker for forms, with validation bounds read from config/rating.php
RatingColumn Sortable average-rating column, backed by withAvgRating() so it does not N+1
RatingEntry Read-only stars for infolists
RatingsRelationManager Moderate the ratings and reviews a record received

Related packages

Testing

composer test

Credits

This package began life in 2015 as an MIT-licensed package by DraperStudio / PackageBackup, whose original repository is no longer published. It has been maintained by GAIT ever since, across Laravel 5 through 13. The original copyright notice is retained in LICENSE.

Sponsor

Become a Sponsor

More in this ecosystem