NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Reviews for Laravel

Creating reviews

Reviews::for($subject) scopes to the subject and ->by($author) starts a fluent PendingReview; chain setters and finish with create():

use RoundlyConsulting\Reviews\Facades\Reviews;

$review = Reviews::for($restaurant)   // the reviewable subject
    ->by($user)                       // the author
    ->rating(5)
    ->title('Loved it')
    ->content('Great place')
    ->meta(['visit' => 'dinner'])     // array or Collection
    ->create();

// Rating-only (no text):
Reviews::for($restaurant)->by($user)->rating(4)->create();

// Text-only (no rating):
Reviews::for($restaurant)->by($user)->content('Friendly staff, slow kitchen')->create();

// Force-approve on create (bypasses moderation):
Reviews::for($restaurant)->by($user)->content('Trusted')->approved()->create();

// Mark a verified purchase / verified reviewer:
Reviews::for($restaurant)->by($user)->rating(5)->verified()->create();

Or start from the subject — addReview() goes through the same manager:

// Start from the subject — for($restaurant)->by($user) is filled in for you:
$restaurant->addReview($user)->rating(5)->content('Best ramen in town')->create();

Builder reference

MethodPurpose
Reviews::for(Model $reviewable)The subject being reviewed — returns a ReviewableScope.
->by(Model $author)The author — returns the PendingReview; subject and author are fixed from here.
rating(?int $rating)Score within min_rating…max_rating; null for text-only.
title(?string $title)Optional headline.
content(?string $content)Optional body text.
meta(array|Collection|null $meta)Free-form metadata, stored as JSON.
approved()Approve on create, bypassing the moderator.
verified(bool $verified = true)Mark a verified purchase / reviewer.
withPhoto() / withPhotos()Attach uploaded files (or local paths).
withDraftPhoto(string $token)Bind a media-library draft upload.
withPhotoFromDisk($path, $disk)Attach a file already on a disk.
withPhotoFromUrl(string $url)Download and attach a remote image.
create()Validate, moderate, persist and return the Review.

What create() does

  • Rejects an empty review — a review needs a rating, content, or both. Content that is only whitespace counts as none; the text itself is stored as given, never trimmed.
  • Validates the rating against min_rating…max_rating.
  • With one_per_author on, rejects a second non-deleted review by the same author for the same subject — owner responses don’t count. The check runs again under the author’s row lock with a locking read, so concurrent submissions can’t both get in — even inside a host transaction under MySQL’s default REPEATABLE READ. One limit: on PostgreSQL, a host transaction at REPEATABLE READ (not the default) can’t see a review committed after its first read, so the check can miss it.
  • Sets the status: approved() or auto_approve approves immediately and stamps approved_at; otherwise the configured moderator decides — with the author and subject already associated — falling back to default_status.
  • Saves the review and attaches its photos in one transaction on the review model’s connection — a failed upload or an over-limit request rolls the whole create back. When media-library’s connection (photos attached) or the author’s (one_per_author) differs, it gets its own transaction around that one: one commit per connection, not a distributed transaction.
  • Dispatches ReviewCreated after the transaction, so listeners see the persisted photos — then ReviewApproved or ReviewRejected when the review lands approved or rejected.

Validation & exceptions

Every package exception extends RoundlyConsulting\Reviews\Exceptions\ReviewException (a RuntimeException), so one catch covers them all:

use RoundlyConsulting\Reviews\Exceptions\InvalidRatingException;
use RoundlyConsulting\Reviews\Exceptions\InvalidReviewException;
use RoundlyConsulting\Reviews\Exceptions\ReviewException;

try {
    Reviews::for($restaurant)->by($user)->rating(7)->create();
} catch (InvalidRatingException $e) {
    // "The rating 7 must be between 1 and 5."
} catch (InvalidReviewException $e) {
    // empty review, duplicate author, too many photos, photos disabled
} catch (ReviewException $e) {
    // the common base of every package exception
}
ExceptionThrown when
InvalidReviewException::empty()A review has no rating and no content beyond whitespace — on create or after an update.
InvalidReviewException::duplicate()one_per_author is on and the author already reviewed the subject.
InvalidReviewException::tooManyPhotos()The photos would exceed photos.max.
InvalidReviewException::photosDisabled()A photo is attached while photos.enabled is false.
InvalidRatingException::outOfRange()A rating falls outside min_rating…max_rating — on create or update.

Every exception message — rating, empty, duplicate and both photo errors — and the banned-word reason resolve in the current locale. English and Slovak ship; publish reviews-translations and edit lang/vendor/reviews only to reword them or add a language. photos.too_many is a plural line, rendered with trans_choice() on :max:

KeyEnglish default
reviews::messages.rating.out_of_rangeThe rating :given must be between :min and :max.
reviews::messages.review.emptyA review must have either a rating or content.
reviews::messages.review.duplicateThis author has already reviewed this subject.
reviews::messages.review.banned_wordThis review contains language that is not allowed.
reviews::messages.photos.too_many{1} A review may have at most :max photo.|[0,*] A review may have at most :max photos.
reviews::messages.photos.disabledReview photos are disabled; enable reviews.photos.enabled to attach photos.

The DTO and the action

The subject and author are fixed once by() returns the builder, so it can’t be re-pointed at another subject. create() hands a CreateReviewData to Reviews::create(), which runs the CreateReview action — the same operation in one call:

use RoundlyConsulting\Reviews\DataTransferObjects\CreateReviewData;
use RoundlyConsulting\Reviews\Facades\Reviews;

// The builder's terminal step, in one call:
$review = Reviews::create(new CreateReviewData(author: $user, reviewable: $restaurant, rating: 5));

Or run the action yourself (see DI and actions):

use RoundlyConsulting\Reviews\Actions\CreateReview;
use RoundlyConsulting\Reviews\DataTransferObjects\CreateReviewData;

$review = app(CreateReview::class)->execute(new CreateReviewData(
    author: $user,
    reviewable: $restaurant,
    content: 'Great place',
    title: 'Loved it',
    rating: 5,
    meta: collect(['visit' => 'dinner']),
    approved: false,
    verified: true,
));

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.