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.
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.1, 8.2 or 8.3
- Laravel 10
- MySQL 5.7+, MariaDB 10.6+, PostgreSQL, or SQLite
#Composer Installation
composer require pharaonic/laravel-ratingsThe service provider is registered automatically.
#Run Migrations
The package loads its migration automatically, so the ratings table is created with your other migrations:
php artisan migrateTo change the migration before it runs, publish it into database/migrations:
php artisan vendor:publish --tag=ratings-migrationsThe 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.
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:
php artisan vendor:publish --tag=ratings-configThe file is published to config/pharaonic/ratings.php.
#Tested Databases
This release line is tested on PHP 8.1, 8.2 and 8.3 with Laravel 10 against:
| Database | Versions tested |
|---|---|
| SQLite | the versions bundled with PHP 8.1, 8.2 and 8.3 |
| MySQL | 5.7, 8.4 |
| MariaDB | 10.6 |
| PostgreSQL | 16 |
SQL Server is not tested.
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.
1return [2 'table' => 'ratings',3 4 'min' => 1,5 'max' => 5,6 'step' => 1,7 8 'cleanup_on_delete' => true,9];| Key | Default | Description |
|---|---|---|
table | 'ratings' | The table the migration creates and the package reads and writes. |
min | 1 | The lowest value a rating may take. |
max | 5 | The highest value a rating may take. |
step | 1 | The distance between two allowed values: min, min + step, min + 2 * step, … up to max. |
cleanup_on_delete | true | Delete 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:
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.
1namespace App\Models;2 3use Illuminate\Foundation\Auth\User as Authenticatable;4use Pharaonic\Laravel\Ratings\Concerns\CanRate;5 6class User extends Authenticatable7{8 use CanRate;9}1namespace App\Models;2 3use Illuminate\Database\Eloquent\Model;4use Pharaonic\Laravel\Ratings\Concerns\HasRatings;5 6class Product extends Model7{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 commentrateBy() 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(); // 510$result->current(); // 311 12$result = $user->rate($product, 3); // the same value again13$result->unchanged(); // true: no query wrote anything14$result->changed(); // false15 16$result->rating(); // the Rating model in every casechanged() 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(); // 42$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 User10$rating->rateable; // the ProductratingFor() 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 it2$user->unrate($product); // false: there was none3$product->unrateBy($user); // the same, from the other side#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:
1namespace App\Models; 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(): array11 {12 return ['min' => 0, 'max' => 10, 'step' => 0.5];13 }14}1$user->rate($course, 8.5); // allowed2$user->rate($course, 8.75); // InvalidArgumentException: not on the rating scale of [App\Models\Course]: 0 to 10 in steps of 0.53$user->rate($course, 11); // InvalidArgumentExceptionratingScale() 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.30000000000000004A 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.
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(); // 42$product->averageRating(); // 3.75, or null when there are no ratings3$product->ratingsSum(); // 15, or 0 when there are no ratings4$product->ratingDistribution(); // [2 => 1, 4 => 2, 5 => 1]| Method | Returns | Without ratings |
|---|---|---|
ratingsCount() | int | 0 |
averageRating() | float, rounded half up to 4 decimal places (4.3333) | null |
ratingsSum() | int when whole, float otherwise | 0 |
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 more2Product::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; // 211$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:
1use Illuminate\Database\Eloquent\Relations\Relation;2 3public function boot()4{5 Relation::morphMap([6 'user' => \App\Models\User::class,7 'product' => \App\Models\Product::class,8 ]);9}#Enforced morph maps
With Relation::enforceMorphMap(), every model that rates or is rated needs an alias. Rating with an unmapped model throws Laravel's ClassMorphViolationException before anything is written:
1Relation::enforceMorphMap([2 'user' => \App\Models\User::class,3 'product' => \App\Models\Product::class,4]);5 6$user->rate($course, 4); // ClassMorphViolationException: No morph map defined for model [App\Models\Course].#Type length
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:
| Event | Dispatched by | Not dispatched |
|---|---|---|
Pharaonic\Laravel\Ratings\Events\RatingCreated | rate(), rateBy() when they insert the rating | When the rating already existed |
Pharaonic\Laravel\Ratings\Events\RatingUpdated | rate(), rateBy() when they change the value or the comment | When nothing changed; for a direct $rating->save() or update() |
Pharaonic\Laravel\Ratings\Events\RatingRemoved | unrate(), unrateBy() when they delete the rating | When 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.
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}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
falsefromsaving,creating, orupdatingcancels the write.rate()then throws aRuntimeException("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()andsave()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 gone2$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 kept2$post->restore(); // the same ratings, average included3$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:
1'cleanup_on_delete' => false,#What the cleanup does not cover
- It is a bulk delete: no
RatingRemovedevent and noRatingmodel events. - It runs from the deleted model's
deletedevent, 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\Models\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.
| Method | Description | Returns |
|---|---|---|
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.
| Method | Description | Returns |
|---|---|---|
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 |
| Scope | Description |
|---|---|
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().
| Method | Description | Returns |
|---|---|---|
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
| Member | Description |
|---|---|
value | The value: int when whole, float otherwise. Checked against the scale on save. |
comment | The comment, or null. |
rater_type, rater_id | The rater's morph type and id. |
rateable_type, rateable_id | The 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
| Event | Properties |
|---|---|
RatingCreated | $rating, $rater, $rateable |
RatingUpdated | $rating, $rater, $rateable, $previousValue, $previousComment |
RatingRemoved | $rating, $rater, $rateable |
All in Pharaonic\Laravel\Ratings\Events.
#Exceptions
| Exception | When |
|---|---|
InvalidArgumentException | An unsaved model in a write, a value that is not a number or not on the scale, an invalid comment, an invalid minimum average. |
UnexpectedValueException | An invalid scale, or a direct save of a rating whose rated model no longer exists. |
RuntimeException | A model event cancelled the save, or the rating kept being deleted while it was written. |
Illuminate\Database\ClassMorphViolationException | Relation::enforceMorphMap() is on and a model has no alias. Nothing is 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, Favorites | Bookmarks | Ratings | |
|---|---|---|---|
| Stored with the pair | nothing | JSON metadata | a value on a scale and a comment |
| Write | like() returns bool | bookmark() returns the Bookmark | rate() returns a RatingResult |
| Toggle | toggleLike() | toggleBookmark() | none: a rating has a value, so it is set or removed |
| Read one | hasLiked() | bookmarkOf(), hasBookmarked() | ratingFor(), hasRated() |
| Remove | unlike() | unbookmark() | unrate() |
| Updated event | no | BookmarkUpdated | RatingUpdated, with the previous value and comment |
| Aggregates | withCount() | 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 aRatingResult. - No toggle. Toggling a value makes no sense;
rate()sets it andunrate()removes it. - Omitted is not null. Like bookmark metadata, an omitted comment keeps what is stored. Unlike bookmarks, where
nullalso keeps the metadata,nullclears 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.
1namespace App\Http\Controllers; 2 3use App\Models\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}1use App\Http\Controllers\ProductRatingController;2 3Route::post('products/{product}/rating', [ProductRatingController::class, 'store'])->middleware('auth');4Route::delete('products/{product}/rating', [ProductRatingController::class, '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
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} 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:
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
1class Course extends Model2{3 use HasRatings;4 5 public function ratingScale(): array6 {7 return ['min' => 0.5, 'max' => 5, 'step' => 0.5];8 }9}1$user->rate($course, 4.5);2$course->averageRating(); // 4.53Course::whereRatingAtLeast(4)->orderBy('title')->paginate();#5. Top-rated products with their averages
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\Models\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\Models\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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.
#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 explicitnull:rate($product, 5)rate($product, 4, 'Nice')rate($product, 4, null)rate($product, $sameValue)unchanged: nothing is writtenrate($product, $sameValue, 'Different')updated: only the comment changedrateBy()follows the same rules. Named arguments are fine:comment: nullclears the comment just like a third positionalnull.#What a comment may be
null, so a form's empty textarea clears it.null, which clears it.Anything else (an integer, an array,
true) throws anInvalidArgumentException, and so does a string that is not valid UTF-8 or is longer than 65,535 bytes, the size of a MySQLTEXTcolumn. The same rules apply when you set$rating->commentdirectly.#Ratings with a comment
ratingsWithComments()is theratings()relation limited to ratings that have a comment. Because blank comments are stored asnull, it never returns an empty one: