Laravel Packagev7.0.0MIT License

Ratings

Ratings lets any Eloquent model rate any other: a user rates a product, a customer rates a course, a bot scores a comment. Each rater has one rating per model, with a value on a scale and an optional comment. rate() creates or updates it and tells you which one happened, and the aggregates you need for a product page (average, count, sum, distribution) are single SQL queries.

One Rating Per Rater

A unique index keeps each rater and rated model to one row, even when requests arrive at the same moment.

Create or Update in One Call

rate() inserts the rating or changes it, and returns whether it was created, updated, or unchanged, with the value before and after.

Optional Comments

Leave the comment out to keep the one already stored, pass a string to replace it, or pass null to clear it.

Exact Scales

One to five stars by default, any range and step per model, such as 0 to 10 in half steps, checked without float rounding errors.

Aggregates in SQL

Average, count, sum, and distribution of a model's ratings come from the database, never from loading every rating into memory.

Any Model, Any Key

Integer, UUID, and ULID keys and morph map aliases work on both sides, through native Eloquent relations.

Quick Tip

The rating is a regular Eloquent model. Get it with ratingFor() and change it with update() or save(): the same scale and comment rules apply.

#Installation

Install the package with Composer and create its table.

#Requirements

  • PHP 8.0
  • Laravel 7
  • MySQL 5.7+, MariaDB 10.6+, PostgreSQL, or SQLite

#Composer Installation

Terminal
composer require pharaonic/laravel-ratings

The service provider is registered automatically.

#Run Migrations

The package loads its migration automatically, so the ratings table is created with your other migrations:

Terminal
php artisan migrate

To change the migration before it runs, publish it into database/migrations:

Terminal
php artisan vendor:publish --tag=ratings-migrations

The published file keeps the package's file name, 2026_11_13_000001_create_pharaonic_ratings_table.php, so Laravel runs your copy instead of the package's, never both.

Existing ratings table

The migration stops with an error when the table already exists. Set pharaonic.ratings.table to another name before migrating, or move that table out of the way first.

#Publish Configuration

Publish the configuration only when you need another table name, another default scale, or want to turn off cleanup on delete:

Terminal
php artisan vendor:publish --tag=ratings-config

The file is published to config/pharaonic/ratings.php.

#Tested Databases

This release line is tested on PHP 8.0 with Laravel 7 against:

DatabaseVersions tested
SQLitethe version bundled with PHP 8.0
MySQL5.7, 8.4
MariaDB10.6
PostgreSQL16

SQL Server is not tested.

Installation Complete

You're all set! Add CanRate and HasRatings to your models, as Basic Usage shows.

#Configuration

Every setting has a default, so the package works without a configuration file. Publish it with php artisan vendor:publish --tag=ratings-config to change one; it lands in config/pharaonic/ratings.php.

config/pharaonic/ratings.php
1return [
2 'table' => 'ratings',
3 
4 'min' => 1,
5 'max' => 5,
6 'step' => 1,
7 
8 'cleanup_on_delete' => true,
9];
KeyDefaultDescription
table'ratings'The table the migration creates and the package reads and writes.
min1The lowest value a rating may take.
max5The highest value a rating may take.
step1The distance between two allowed values: min, min + step, min + 2 * step, … up to max.
cleanup_on_deletetrueDelete the ratings a model gave or received when it is deleted (or force deleted).

#The table name

Set table before the first php artisan migrate. Changing it afterwards does not rename the existing table: rename it with a migration of your own, then change the setting.

#The default scale

min, max, and step are the scale of every rated model that does not define its own. They may be integers, floats, or decimal strings with up to 4 decimal places:

config/pharaonic/ratings.php
1'min' => 0,
2'max' => 10,
3'step' => '0.5',

step must be greater than 0, max greater than min, and max - min a whole number of steps, so that max is itself a valid rating. A scale that breaks these rules throws an UnexpectedValueException the first time it is used. See Rating Scales for per-model scales and what happens to stored ratings when a scale changes.

#Basic Usage

Add CanRate to the model that gives ratings and HasRatings to the model that receives them. The rater does not have to be a user, and a model can use both traits.

app/User.php
1namespace App;
2 
3use Illuminate\Foundation\Auth\User as Authenticatable;
4use Pharaonic\Laravel\Ratings\Concerns\CanRate;
5 
6class User extends Authenticatable
7{
8 use CanRate;
9}
app/Product.php
1namespace App;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Ratings\Concerns\HasRatings;
5 
6class Product extends Model
7{
8 use HasRatings;
9}

#Rate a model

rate() creates the rating, or updates the rating this user already gave. The value must be on the product's scale (1 to 5 by default):

1$result = $user->rate($product, 5);
2$result = $user->rate($product, 4, 'Nice product'); // with a comment

rateBy() is the same call from the other side, with the same result:

1$result = $product->rateBy($user, 4, 'Nice product');

Both models must be saved. An unsaved one throws an InvalidArgumentException, and nothing is written.

#The result

rate() returns a RatingResult that says what the call did:

1$result = $user->rate($product, 5); // the first rating
2$result->created(); // true
3$result->status(); // 'created'
4$result->previous(); // null
5$result->current(); // 5
6 
7$result = $user->rate($product, 3); // a different value
8$result->updated(); // true
9$result->previous(); // 5
10$result->current(); // 3
11 
12$result = $user->rate($product, 3); // the same value again
13$result->unchanged(); // true: no query wrote anything
14$result->changed(); // false
15 
16$result->rating(); // the Rating model in every case

changed() is true for created and updated. The result is a snapshot of that call: saving the rating again later does not change it. A write that a model event cancels is never reported: it throws a RuntimeException instead.

#Values

A value is an integer, a float, or a decimal string, with up to 4 decimal places. Ratings come back as an int when they are whole and a float otherwise:

1$user->rate($product, '4')->current(); // 4
2$user->rate($course, 4.5)->current(); // 4.5 (on a scale with half steps)
3$user->rate($product, 4.0)->unchanged(); // true: 4.0 is the value already stored

#Read a rating

1$user->hasRated($product); // true
2$product->ratedBy($user); // true
3 
4$rating = $user->ratingFor($product); // the Rating model, or null
5$rating = $product->ratingFrom($user); // the same
6 
7$rating->value; // 3
8$rating->comment; // 'Nice product'
9$rating->rater; // the User
10$rating->rateable; // the Product

ratingFor() and ratingFrom() only read; they never create a rating. For an unsaved model, they return null and hasRated() returns false.

#Edit a rating with Eloquent

The rating is a regular Eloquent model, so plain update() and save() change it. The same rules apply as for rate(): the value must be on the rated model's scale, and a blank comment is stored as null.

1$rating = $user->ratingFor($product);
2 
3$rating->update(['comment' => 'Updated review']);
4$rating->update(['value' => 4, 'comment' => 'Changed my mind']);
5 
6$rating->comment = null;
7$rating->save();

value and comment are the fillable attributes. The package has no method for each field on purpose: update() already does it.

#Remove a rating

1$user->unrate($product); // true: this call removed it
2$user->unrate($product); // false: there was none
3$product->unrateBy($user); // the same, from the other side

#Comments

Each rating has one optional comment, stored with it. It is part of the rating, not a separate review.

#Leave it out, set it, or clear it

rate() tells an omitted comment apart from an explicit null:

CallNo rating yetExisting rating
rate($product, 5)Creates it with no commentChanges the value; keeps the comment
rate($product, 4, 'Nice')Creates it with the commentChanges the value and the comment
rate($product, 4, null)Creates it with no commentChanges the value; clears the comment
rate($product, $sameValue)Creates itunchanged: nothing is written
rate($product, $sameValue, 'Different')Creates itupdated: only the comment changed
1$user->rate($product, 4, 'Nice');
2$user->rate($product, 5); // value 5, comment still 'Nice'
3$user->rate($product, 5, comment: null); // value 5, no comment

rateBy() follows the same rules. Named arguments are fine: comment: null clears the comment just like a third positional null.

#What a comment may be

  • A string, stored exactly as you pass it, spaces and line breaks included.
  • A blank string (empty, or only spaces, tabs, and line breaks) means no comment and is stored as null, so a form's empty textarea clears it.
  • null, which clears it.

Anything else (an integer, an array, true) throws an InvalidArgumentException, and so does a string that is not valid UTF-8 or is longer than 65,535 bytes, the size of a MySQL TEXT column. The same rules apply when you set $rating->comment directly.

#Ratings with a comment

ratingsWithComments() is the ratings() relation limited to ratings that have a comment. Because blank comments are stored as null, it never returns an empty one:

1$reviews = $product->ratingsWithComments()
2 ->with('rater')
3 ->latest()
4 ->paginate(10);

#Rating Scales

A scale is a min, a max, and a step: the allowed values are min, min + step, min + 2 * step, … up to max. Every rated model uses the configured scale, 1 to 5 in whole steps, unless it defines its own.

#A scale per model

Define ratingScale() on the rated model:

app/Course.php
1namespace App;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Ratings\Concerns\HasRatings;
5 
6class Course extends Model
7{
8 use HasRatings;
9 
10 public function ratingScale(): array
11 {
12 return ['min' => 0, 'max' => 10, 'step' => 0.5];
13 }
14}
1$user->rate($course, 8.5); // allowed
2$user->rate($course, 8.75); // InvalidArgumentException: not on the rating scale of [App\Course]: 0 to 10 in steps of 0.5
3$user->rate($course, 11); // InvalidArgumentException

ratingScale() is a regular method, so it can depend on the model's own attributes. On a model without your override it returns the configured scale, so you can always read it, for example to draw the right number of stars:

1$product->ratingScale(); // ['min' => 1, 'max' => 5, 'step' => 1]

A model that uses neither HasRatings nor its own ratingScale() can still be rated, on the configured scale.

#Exact decimals

Values and scales may have up to 4 decimal places and 6 whole digits (up to 999999.9999), the precision of the DECIMAL(10,4) column. They are compared as exact decimals, so float noise never refuses a valid rating:

1// On a 0 to 1 scale in steps of 0.1:
2$user->rate($product, 0.1 + 0.2)->current(); // 0.3, although the float is 0.30000000000000004

A value with more decimal places (4.55555), out of range, not finite (NAN, INF), or not a plain decimal ('4,5', '1e3', ' 4', true, null) throws an InvalidArgumentException before anything is written.

#Direct saves follow the scale

$rating->save() and $rating->update() check a new or changed value against the rated model's scale too. To find the scale, the rating loads the rated model when it is not already loaded, through your morph map and without global scopes, so a soft-deleted model is found. Load the relation first when you update many ratings:

1use Pharaonic\Laravel\Ratings\Models\Rating;
2 
3Rating::with('rateable')->whereIn('id', $ids)->get()->each->update(['value' => 5]);

A rating whose rated model no longer exists cannot change its value: the save throws an UnexpectedValueException that says so, rather than guessing a scale. Its comment can still be edited, and it can be deleted.

Query updates skip the checks

Laravel runs no model code for query updates such as Rating::query()->update([...]) or raw SQL, so neither the scale check nor the comment rules apply there. Use them only with values you know are valid.

#Changing a scale

Changing a scale never rewrites stored ratings: they keep their values and still count in every average. A stored value only meets the new scale when it is written again:

  • a save that changes only the comment does not check the value, so old ratings stay editable;
  • rate() with the old, now invalid value is refused, and so is a save that sets another invalid value;
  • a valid new value is accepted as usual.

To move existing ratings to a new scale, convert them in a migration of your own, for example from 1–5 to 0–10 with value * 2, before or together with the scale change.

#Queries and Aggregates

Every aggregate is one SQL query on the rated model's ratings; none of them loads the ratings into memory.

#On one model

1$product->ratingsCount(); // 4
2$product->averageRating(); // 3.75, or null when there are no ratings
3$product->ratingsSum(); // 15, or 0 when there are no ratings
4$product->ratingDistribution(); // [2 => 1, 4 => 2, 5 => 1]
MethodReturnsWithout ratings
ratingsCount()int0
averageRating()float, rounded half up to 4 decimal places (4.3333)null
ratingsSum()int when whole, float otherwise0
ratingDistribution()array of value => count, by ascending value[]

The average is rounded to 4 decimal places, the precision ratings are stored with, so it is the same on every database. Round it further for display: round($product->averageRating() ?? 0, 1).

ratingDistribution() comes from one grouped query and lists only the values that occur. Its keys are the actual values, so it follows any scale. PHP arrays cannot have float keys, so whole values are integer keys and the others decimal strings:

1$course->ratingDistribution(); // [0 => 1, '0.5' => 1, '4.5' => 2, 10 => 1]

To show every star with a zero for the missing ones, merge it with the scale:

1$counts = $product->ratingDistribution();
2$scale = $product->ratingScale();
3 
4foreach (range($scale['max'], $scale['min'], -$scale['step']) as $stars) {
5 echo $stars.' stars: '.($counts[$stars] ?? 0);
6}

#Relations

ratings() (on HasRatings) and givenRatings() (on CanRate) are MorphMany relations, so everything Eloquent offers works with them:

1$product->ratings()->with('rater')->latest()->paginate(20);
2$product->ratings()->where('value', '>=', 4)->count();
3$user->givenRatings()->with('rateable')->get();
4 
5Product::with('ratings')->get();
6Product::has('ratings')->get();
7Product::whereHas('ratings', function ($query) {
8 $query->where('value', 1);
9})->get();

#Filter models

whereRatedBy() keeps the models a rater rated:

1Product::whereRatedBy($user)->latest()->paginate();

whereRatingAtLeast() keeps the models whose average rating is at least the given number, not the models that have one rating that high. Models without ratings never match:

1Product::whereRatingAtLeast(4)->paginate(); // average of 4 or more
2Product::whereRatingAtLeast(4.5)->whereRatedBy($user)->get();

It compares the average rounded to 4 decimal places, as averageRating() returns it, so the two always agree. Both scopes combine with any other constraint and with pagination.

#Counts and averages for a list

withCount() adds a ratings_count to each model in the same query. On Laravel 6, withCount() also takes another aggregate through a custom column, which gives an average for each model without one query per model:

1use Illuminate\Support\Facades\DB;
2 
3$products = Product::withCount([
4 'ratings',
5 'ratings as ratings_avg_value' => function ($query) {
6 $query->select(DB::raw('avg(value)'));
7 },
8])->get();
9 
10$products->first()->ratings_count; // 2
11$products->first()->ratings_avg_value; // "4.50000000" (the database's own type)

The column comes back as your database returns it; cast and round it for display.

#Query the ratings

The Pharaonic\Laravel\Ratings\Models\Rating model has two scopes for the two sides:

1use Pharaonic\Laravel\Ratings\Models\Rating;
2 
3Rating::query()->whereRater($user)->get();
4Rating::query()->whereRateable($product)->where('value', 5)->count();

An unsaved model matches nothing in every scope and relation.

#Morph Maps and Ids

The ratings table stores each side as a morph type and an id, so any two models can rate each other.

#Morph maps

The type is what getMorphClass() returns: the class name, or its alias when you register a morph map. Register the aliases before rating anything, or existing rows keep the class names:

app/Providers/AppServiceProvider.php
1use Illuminate\Database\Eloquent\Relations\Relation;
2 
3public function boot()
4{
5 Relation::morphMap([
6 'user' => \App\User::class,
7 'product' => \App\Product::class,
8 ]);
9}

The type column holds up to 191 characters. A longer class name throws an InvalidArgumentException that suggests a morph map alias.

#Integer, UUID, and ULID keys

Ids are stored as strings of up to 64 characters, so integer, UUID, and ULID keys share one table, on either side. Relations, eager loading, whereHas(), withCount(), and both scopes compare the keys as strings, which PostgreSQL requires for integer and UUID columns.

#The same id, another type

A rating is identified by both types and both ids. User 1 and Bot 1 are different raters, and Post 1 and Product 1 are different rated models, so each pair has its own rating.

#Events

The package dispatches three events, only when a call actually changed the table:

EventDispatched byNot dispatched
Pharaonic\Laravel\Ratings\Events\RatingCreatedrate(), rateBy() when they insert the ratingWhen the rating already existed
Pharaonic\Laravel\Ratings\Events\RatingUpdatedrate(), rateBy() when they change the value or the commentWhen nothing changed; for a direct $rating->save() or update()
Pharaonic\Laravel\Ratings\Events\RatingRemovedunrate(), unrateBy() when they delete the ratingWhen there was none, and for the cleanup on delete

Each has the public properties $rating, $rater, and $rateable. RatingUpdated also has $previousValue and $previousComment, what the rating held before the call, so a listener can adjust a stored total without reading the table again. In RatingRemoved, $rating is the deleted row, with exists false.

app/Listeners/UpdateProductScore.php
1namespace App\Listeners;
2 
3use Pharaonic\Laravel\Ratings\Events\RatingUpdated;
4 
5class UpdateProductScore
6{
7 public function handle(RatingUpdated $event)
8 {
9 $event->rateable->increment('score_total', $event->rating->value - $event->previousValue);
10 }
11}
app/Providers/EventServiceProvider.php
1protected $listen = [
2 \Pharaonic\Laravel\Ratings\Events\RatingUpdated::class => [
3 \App\Listeners\UpdateProductScore::class,
4 ],
5];

#When listeners run

Each write runs in a short transaction of its own, and the event is dispatched right after it commits. When the database reports a deadlock, the write is retried before any event, so a retried write dispatches once.

When you rate inside your own database transaction, the package's transaction is a savepoint in yours, so listeners run before your transaction commits, and a rollback afterwards does not take the event back. Make a listener that must only see committed ratings a queued listener dispatched after your transaction, or call the package outside it.

#Model events

Inserts and updates go through Eloquent's save(), so the Rating model's own saving, creating, created, updating, updated, and saved events fire. A call that changes nothing fires none of them.

  • A listener that returns false from saving, creating, or updating cancels the write. rate() then throws a RuntimeException ("A model event cancelled saving the rating…") instead of returning a result for a rating that isn't stored, and no package event is dispatched.
  • Direct update() and save() calls fire these model events only, not the package events.

The removal deletes the row with a query, so the Rating model's deleting and deleted events do not fire. Listen to RatingRemoved instead.

#Deletes and Soft Deletes

When a model that uses CanRate or HasRatings is deleted, the ratings it gave or received are deleted with it. A model with both traits loses both.

1$product->delete(); // its ratings are gone
2$user->delete(); // the ratings it gave are gone

#Soft deletes

A soft delete keeps every rating, so restoring the model brings its ratings back. A force delete removes them:

1$post->delete(); // soft delete: ratings kept
2$post->restore(); // the same ratings, average included
3$post->forceDelete(); // ratings deleted

#Turning cleanup off

Set cleanup_on_delete to false to keep the ratings of deleted models, for example when you archive them yourself:

config/pharaonic/ratings.php
1'cleanup_on_delete' => false,

#What the cleanup does not cover

  • It is a bulk delete: no RatingRemoved event and no Rating model events.
  • It runs from the deleted model's deleted event, so Laravel's query deletes (Product::query()->delete()), raw SQL, and database cascades skip it, and leave ratings that point at missing rows.

To find such orphaned ratings later, check them against their table in chunks:

1use App\Product;
2use Pharaonic\Laravel\Ratings\Models\Rating;
3 
4Rating::query()
5 ->where('rateable_type', (new Product)->getMorphClass())
6 ->chunkById(500, function ($ratings) {
7 $existing = Product::query()
8 ->whereIn('id', $ratings->pluck('rateable_id')->all())
9 ->pluck('id')
10 ->map(fn ($id) => (string) $id)
11 ->all();
12 
13 $orphans = $ratings->reject(fn ($rating) => in_array($rating->rateable_id, $existing, true));
14 
15 Rating::query()->whereKey($orphans->modelKeys())->delete();
16 });

Run it from a command or a scheduled job of your own; the package schedules nothing.

#Integrity and Concurrency

Two requests can rate the same model for the same user at the same moment: a double-clicked button, a retried request, two tabs. The package keeps the table and the results correct when they do.

#One rating per pair

The table has a unique index on the rater's and the rated model's type and id. It is what keeps each pair to one rating: when two requests insert the same pair together, the database accepts one insert and refuses the other, and the refused call updates the row the other one created. One call reports created; the other reports updated or unchanged.

The index fits the key limits of MySQL and MariaDB with utf8mb4 (2,040 bytes, under InnoDB's 3,072), and also serves every query on one model's ratings, aggregates included.

#The last committed write wins

Each write locks the rating's row before it reads what the rating holds. Concurrent writes to one rating therefore run one after the other, and each call's previous() is the value the write before it committed:

  • no update is lost: every call's change was stored, then replaced only by a later one;
  • the stored rating is the one from the call that committed last, which is not always the request that arrived last;
  • a call without a comment keeps whatever comment the row has when it writes, even if another request just set it.

#Removal during a write

When another request deletes the rating while rate() is writing it, rate() creates it again, so its result describes a rating that exists. If the row keeps vanishing (3 times in a row), it throws a RuntimeException you can retry, rather than reporting a rating that was not stored.

#Transactions and retries

Each write runs in a transaction on the rating model's connection:

  • outside a transaction of yours, a deadlock or a locked SQLite database is retried up to 3 times;
  • inside one, the error goes to you, because the database already rolled your transaction back: retry the whole transaction yourself;
  • the insert runs in a savepoint, so a refused duplicate leaves your PostgreSQL transaction usable;
  • any other database error, including a violation of another unique index of yours, is thrown at once, never retried or hidden.

Every read inside a write uses the write connection, so a lagging read replica cannot hide a rating that was just written.

#What is tested

The tests run separate PHP processes that write to one pair at the same moment, against SQLite, MySQL 5.7 and 8.4, MariaDB 10.6, and PostgreSQL 16: simultaneous first ratings, updates, value-only updates, removals, and ratings mixed with removals.

#API Reference

#CanRate

For the model that gives ratings.

MethodDescriptionReturns
rate(Model $rateable, $value, $comment)Create or update this model's rating of $rateable. Omit $comment to keep the stored one; null clears it.RatingResult
ratingFor(Model $rateable)This model's rating of $rateable.Rating or null
hasRated(Model $rateable)Whether this model rated $rateable.bool
unrate(Model $rateable)Remove the rating. true when this call removed it.bool
givenRatings()The ratings this model gave.MorphMany

#HasRatings

For the model that receives ratings.

MethodDescriptionReturns
rateBy(Model $rater, $value, $comment)The same as $rater->rate($this, $value, $comment).RatingResult
ratingFrom(Model $rater)The rater's rating of this model.Rating or null
ratedBy(Model $rater)Whether the rater rated this model.bool
unrateBy(Model $rater)Remove the rater's rating.bool
ratings()The ratings this model received.MorphMany
ratingsWithComments()ratings() limited to ratings with a comment.MorphMany
ratingScale()The scale of this model; override it for a scale of its own.array with min, max, step
averageRating()The average, rounded to 4 decimal places.float or null
ratingsCount()How many ratings.int
ratingsSum()The sum of the values.int or float
ratingDistribution()How many ratings of each value, by ascending value.array
ScopeDescription
whereRatedBy(Model $rater)Only the models the rater rated.
whereRatingAtLeast($average)Only the models whose average rating is at least $average.

#RatingResult

Pharaonic\Laravel\Ratings\Support\RatingResult, returned by rate() and rateBy().

MethodDescriptionReturns
created()The call inserted the rating.bool
updated()The call changed the value or the comment.bool
unchanged()The rating already held this value and comment.bool
changed()created() or updated().bool
status()'created', 'updated', or 'unchanged'.string
previous()The value before the call; null when it created the rating.int, float, or null
current()The value after the call.int or float
rating()The stored rating.Rating

#Rating model

Pharaonic\Laravel\Ratings\Models\Rating

MemberDescription
valueThe value: int when whole, float otherwise. Checked against the scale on save.
commentThe comment, or null.
rater_type, rater_idThe rater's morph type and id.
rateable_type, rateable_idThe rated model's morph type and id.
rater()MorphTo the model that gave the rating.
rateable()MorphTo the rated model.
whereRater(Model $rater)Scope: ratings given by the rater.
whereRateable(Model $rateable)Scope: ratings of the rated model.

value and comment are fillable.

#Events

EventProperties
RatingCreated$rating, $rater, $rateable
RatingUpdated$rating, $rater, $rateable, $previousValue, $previousComment
RatingRemoved$rating, $rater, $rateable

All in Pharaonic\Laravel\Ratings\Events.

#Exceptions

ExceptionWhen
InvalidArgumentExceptionAn unsaved model in a write, a value that is not a number or not on the scale, an invalid comment, an invalid minimum average.
UnexpectedValueExceptionAn invalid scale, or a direct save of a rating whose rated model no longer exists.
RuntimeExceptionA model event cancelled the save, or the rating kept being deleted while it was written.

#Compared with Likes, Follows, Favorites, and Bookmarks

Ratings belongs to the same family as Laravel Likes, Follows, Favorites, and Bookmarks. They share the same shape: a Can… trait on the actor, a Has… trait on the target, one table with a unique index per pair, string ids for any key type, the same cleanup on delete, and events dispatched only on real changes.

Likes, Follows, FavoritesBookmarksRatings
Stored with the pairnothingJSON metadataa value on a scale and a comment
Writelike() returns boolbookmark() returns the Bookmarkrate() returns a RatingResult
ToggletoggleLike()toggleBookmark()none: a rating has a value, so it is set or removed
Read onehasLiked()bookmarkOf(), hasBookmarked()ratingFor(), hasRated()
Removeunlike()unbookmark()unrate()
Updated eventnoBookmarkUpdatedRatingUpdated, with the previous value and comment
AggregateswithCount()withCount()count, average, sum, distribution, whereRatingAtLeast()

The intentional differences:

  • A result instead of a boolean. A rating can be created, changed, or left as it was, and callers usually need to know which and what the value was before, so rate() returns a RatingResult.
  • No toggle. Toggling a value makes no sense; rate() sets it and unrate() removes it.
  • Omitted is not null. Like bookmark metadata, an omitted comment keeps what is stored. Unlike bookmarks, where null also keeps the metadata, null clears a comment, because "no comment" is a value a rating can have.
  • Scales. Values are checked against the rated model's scale on every write, including direct saves of the model.

#Examples

#1. Rate a product from a form

The controller validates the request against the product's own scale, then reports what happened.

app/Http/Controllers/ProductRatingController.php
1namespace App\Http\Controllers;
2 
3use App\Product;
4use Illuminate\Http\Request;
5 
6class ProductRatingController extends Controller
7{
8 public function store(Request $request, Product $product)
9 {
10 $scale = $product->ratingScale();
11 
12 $data = $request->validate([
13 'value' => ['required', 'numeric', 'min:'.$scale['min'], 'max:'.$scale['max']],
14 'comment' => ['nullable', 'string', 'max:5000'],
15 ]);
16 
17 $result = $request->user()->rate($product, $data['value'], $data['comment'] ?? null);
18 
19 return back()->with('status', $result->created() ? 'Thanks for rating!' : 'Your rating was updated.');
20 }
21 
22 public function destroy(Request $request, Product $product)
23 {
24 $request->user()->unrate($product);
25 
26 return back();
27 }
28}
routes/web.php
1Route::post('products/{product}/rating', 'ProductRatingController@store')->middleware('auth');
2Route::delete('products/{product}/rating', 'ProductRatingController@destroy')->middleware('auth');

The form always sends a comment field, so an empty textarea clears the comment. To change only the stars, call rate() without the third argument.

#2. A product page with the rating summary

app/Http/Controllers/ProductController.php
1public function show(Product $product)
2{
3 return view('products.show', [
4 'product' => $product,
5 'average' => $product->averageRating(),
6 'count' => $product->ratingsCount(),
7 'distribution' => $product->ratingDistribution(),
8 'mine' => auth()->user() ? auth()->user()->ratingFor($product) : null,
9 'reviews' => $product->ratingsWithComments()->with('rater')->latest()->paginate(10),
10 ]);
11}
resources/views/products/show.blade.php
1@if ($average !== null)
2 <p>{{ number_format($average, 1) }} out of 5 ({{ $count }} ratings)</p>
3@endif
4 
5@foreach ([5, 4, 3, 2, 1] as $stars)
6 <p>{{ $stars }} stars: {{ $distribution[$stars] ?? 0 }}</p>
7@endforeach
8 
9@foreach ($reviews as $review)
10 <blockquote>{{ $review->comment }} ({{ $review->value }}/5, {{ $review->rater->name }})</blockquote>
11@endforeach

#3. Edit only the comment

The rating is an Eloquent model, so an edit form updates it directly:

app/Http/Controllers/ReviewController.php
1public function update(Request $request, Product $product)
2{
3 $rating = $request->user()->ratingFor($product);
4 
5 abort_if($rating === null, 404);
6 
7 $rating->update($request->validate(['comment' => ['nullable', 'string', 'max:5000']]));
8 
9 return back();
10}

#4. Half-star course ratings

app/Course.php
1class Course extends Model
2{
3 use HasRatings;
4 
5 public function ratingScale(): array
6 {
7 return ['min' => 0.5, 'max' => 5, 'step' => 0.5];
8 }
9}
1$user->rate($course, 4.5);
2$course->averageRating(); // 4.5
3Course::whereRatingAtLeast(4)->orderBy('title')->paginate();

#5. Top-rated products with their averages

app/Http/Controllers/TopProductsController.php
1use Illuminate\Support\Facades\DB;
2 
3$products = Product::whereRatingAtLeast(4)
4 ->withCount([
5 'ratings',
6 'ratings as ratings_avg_value' => function ($query) {
7 $query->select(DB::raw('avg(value)'));
8 },
9 ])
10 ->orderByDesc('ratings_avg_value')
11 ->take(10)
12 ->get();

#Troubleshooting

#"The rating [x] is not on the rating scale of [App\Product]"

The value is outside the model's scale or between two steps. Check $product->ratingScale(), and validate requests against it before calling rate().

#"max - min must be a whole number of steps"

The scale cannot reach max from min in whole steps, such as 1 to 5 in steps of 0.3. Pick a step that divides max - min.

#"has more than 4 decimal places"

Values are stored with 4 decimal places. Round the value before rating, for example round($value, 4), or use a coarser scale.

#"The [ratings] table already exists"

The migration never runs over an existing table. Set pharaonic.ratings.table to another name before php artisan migrate, or move the old table away.

#"The rateable [App\Course] [5] of the rating does not exist"

A direct save changed the value of a rating whose rated model was deleted without the cleanup, for example by a query delete. Delete the orphaned rating, as Deletes and Soft Deletes shows.

#"A model event cancelled saving the rating"

A saving, creating, or updating listener or observer on the Rating model returned false. The rating was not written.

#The comment disappears when I change the value

A third argument of null, or a blank string, clears the comment. Call rate($model, $value) with two arguments to keep it, or build the arguments so the comment is only passed when the user sent one.

#An average from withCount() looks like "4.50000000"

A custom withCount() column comes back as the database returns it. Cast and round it, or use averageRating() for one model.

#Old ratings fall outside a new scale

Changing a scale does not rewrite stored ratings. Convert them in a migration, as Rating Scales explains.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Ratings!

Moamen Eltouny

@MoamenEltouny

48 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.