sylius/review-bundle
Sylius Review Bundle adds customer product reviews to Sylius stores, with ratings, review submission, moderation, and average rating display. Integrate it to let shoppers share feedback and help others make informed buying decisions.
Do not composer require this. It is a [READ-ONLY] subtree split of the Sylius monorepo targeting Symfony 2.x and PHP 5.x-era runtimes. It will not resolve against a current Laravel composer.json (Illuminate constraints, symfony/* v2 pins, and EOL PHP requirements all collide). Your first action is to clone the repository read-only and treat it as a domain specification written in code, not a dependency.
Read these files first, in this order (paths relative to the bundle root):
Model/ProductReview.php and Model/ProductReviewInterface.php — the canonical column set: title, rating, comment, author (name/email), status, timestamps.Model/ReviewableInterface.php + the ReviewableTrait — how a subject (Product) exposes its reviews and its average rating. This is the polymorphic contract you will port.Resources/config/ (look for state_machine / ReviewTransitions) — the new → accepted | rejected workflow and its guards.Resources/config/doctrine/*.xml — the actual column types, nullable flags, and indexes. Translate these literally before you improve anything.ProductReviewManager, ProductReviewRepository) — where averaging and eligibility checks live.Timebox a one-to-two day spike whose only deliverable is a written spec: the reviews table, the status enum, the transitions, and the aggregation trigger. Do not attempt an install "just to see."
First use case to ship: the schema plus the moderation workflow, with the storefront form stubbed. It is the highest-parity, lowest-risk slice and unblocks the admin queue and the product-page average at the same time.
// database/migrations/xxxx_create_reviews_table.php
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->morphs('reviewable'); // reviewable_type + reviewable_id
$table->foreignId('customer_id')->nullable()->constrained();
$table->string('author_name')->nullable();
$table->string('author_email')->nullable();
$table->string('title')->nullable();
$table->unsignedTinyInteger('rating'); // 1–5, Sylius treats it as required
$table->text('comment')->nullable();
$table->string('status')->default('new'); // new | accepted | rejected
$table->timestamps();
$table->index(['reviewable_type', 'reviewable_id', 'status'], 'reviews_subject_status_idx');
});
Polymorphic subject, not a foreign key to products. Sylius models the review against a review subject rather than the entity directly, which is what makes it reusable across reviewable types. Port that with a Reviewable trait:
trait Reviewable
{
public function reviews(): MorphMany
{
return $this->morphMany(Review::class, 'reviewable');
}
public function acceptedReviews(): MorphMany
{
return $this->reviews()->where('status', ReviewStatus::Accepted);
}
public function recomputeRating(): void
{
$stats = $this->reviews()
->where('status', ReviewStatus::Accepted)
->selectRaw('COUNT(*) as c, AVG(rating) as avg')
->first();
$this->forceFill([
'rating_average' => round((float) $stats->avg, 2),
'rating_count' => (int) $stats->c,
])->save();
}
}
Encode moderation as an enum with explicit transitions. Sylius's state machine is the single most valuable thing to copy; the failure mode it prevents is $review->status = 'accepted' scattered through controllers.
enum ReviewStatus: string {
case New = 'new';
case Accepted = 'accepted';
case Rejected = 'rejected';
}
// Review model
public function accept(): void
{
$this->assertTransition(ReviewStatus::New, ReviewStatus::Accepted);
$this->status = ReviewStatus::Accepted;
$this->save();
$this->reviewable->recomputeRating(); // or dispatch an event
}
Guard the attribute with protected $guarded = ['status'] and keep fillable status-free so mass assignment cannot bypass the transitions.
Aggregation on transition, not on read. The product page reads rating_average / rating_count off the product row (or a cache key), and every accept/reject/delete calls recomputeRating(). Recompute-on-write with a full re-AVG beats incremental +1 counters here because moderation makes rejections and reversals routine.
Admin moderation queue. Rebuild the Sylius grid as a filtered, paginated index — status filter defaulting to new, bulk accept/reject, and a subject column. Whatever the stack (Filament, Nova, or a plain Blade table), the requirements from the original grid are: filter by status, filter by product, search by author, and row actions for each transition.
Storefront submission. A FormRequest mirroring the bundle's validation, plus rate limiting per IP/customer, a honeypot, and ->middleware('throttle:reviews'). Decide guest reviews up front — the original allows them with name/email captured.
Keep the port behind an interface. Wrap it in a ReviewService contract. If a maintained Laravel package appears, or reviews become a growth channel and you buy a vendor, you swap the implementation without touching controllers or views.
onDelete cascade is invisible until a product is deleted.morphTo cannot have a database foreign key. Nothing stops a review from orphaning when its subject is deleted. Add a scheduled prune job, or soft-delete subjects, and decide deliberately which.(reviewable_type, reviewable_id, status) index is not optional on a real catalog; polymorphic lookups without it degrade badly, and the moderation dashboard needs status in the same index to paginate.lockForUpdate on the subject row, or push recompute to a queued job with a unique key per subject.AVG(rating) silently shifts and your star display needs a separate count. Decide before the migration.sylius_review.* settings (author-display behaviour, eligibility rules) are Symfony tree config. Re-express them as config/reviews.php with env() defaults: show_customer_name, allow_guest_reviews, require_approval, min_length.AggregateRating structured data for SEO. Budget each as its own task rather than assuming a config flag.author_email on an unmoderated public write endpoint means you need a retention/anonymisation policy and a way to honour deletion requests; the Symfony original offers nothing here.ReviewService interface, FormRequest + custom validation rules, model observers, domain events (ReviewAccepted, ReviewRejected), and policies for who may moderate. Design to those seams now so the swap-out later is cheap.How can I help you explore Laravel packages today?