Skip to content

Product Reviews

Flow ID: CF-16 Module(s): eshop, Product domain Complexity: Medium Last Updated: 2026-05-07

Business Overview

Product reviews enable customers to share star ratings (1-5) and written feedback. Reviews go through an approval workflow: submitted as pending, then approved or rejected by admin. Average ratings display on product pages.

Key business behaviors:

  • One review per customer per product
  • Approval states: 0=pending, 1=approved, 2=rejected
  • Star ratings 1-5 integer scale
  • Language-scoped reviews
  • Review request emails via cron job (Google, Facebook, Skroutz platforms)

API Reference

REST Endpoints

MethodPathAuthDescription
GET/rest/product/reviewGuestList reviews (filter by productId, starPoints, active)
GET/rest/product/review/{id}GuestShow single review
GET/rest/product/review/itemGuestShow single review (item endpoint)
POST/rest/product/reviewBackendCreate review
POST/rest/product/review/{id}BackendUpdate (admin approval)
DELETE/rest/product/review/{id}BackendDelete review

Filters: id, productId, customerId, starPoints, active, lang, nickname, content
Sorts: id, productId, customerId, starPoints, reviewDate, active
Relations: product (belongs_to)


Domain Layer

ComponentPath
REST Controllersrc/Rest/Product/Controllers/Review.php
Servicesrc/Domains/Product/Review/Service.php
WriteServicesrc/Domains/Product/Review/WriteService.php
Validatorsrc/Domains/Product/Review/Validator.php

Dual path: Legacy safeAddReview() for storefront + REST for API.

Rating Aggregation

RatingAggregator (src/Domains/Product/Review/RatingAggregator.php) keeps two cached columns on shop_product in sync so product list views can render star ratings without issuing per-product review queries (eliminates N+1 for product cards).

What is cached

Column on shop_productTypeDefaultMeaning
average_ratingDECIMAL(3,2) NOT NULL0.00AVG(star_points) of active reviews (active=1), rounded to 2 d.p.
review_countINT(10) UNSIGNED NOT NULL0COUNT(*) of active reviews (active=1)

Recompute query

RatingAggregator::recomputeForProduct(int $productId): void runs:

sql
SELECT AVG(star_points), COUNT(*)
FROM shop_product_reviews
WHERE product_id = ? AND active = 1

and writes the result back to shop_product via a CI DB update. The constructor is side-effect-free; the DB connection is lazy-loaded.

What triggers a recompute

Modern path (src/Domains/Product/Review/WriteService.php):

  • create() — recomputes for the new review's $entity->product_id
  • update() — collects $original->product_id and $entity->product_id via array_unique(array_filter([...])), recomputes for each distinct product id affected
  • delete() — recomputes for the deleted review's $original->product_id

Legacy path (ecommercen/eshop/models/Adv_product_reviews_model.php):

  • addReview() — customer storefront submission
  • updateReview() — individual review edit
  • setReviewStatus() — single-review status change (admin moderation: approve/reject)
  • setReviewsStatusBatch() — bulk status change (admin bulk moderation)

The legacy path covers creation, update, and status flips, but NOT hard-deletes — Adv_product_reviews_model has no delete method, so a hard-delete through any legacy admin path bypasses the aggregator and leaves shop_product.average_rating and shop_product.review_count stale. Modern WriteService::delete() does invoke the aggregator.

Surfacing in the REST API

src/Rest/Product/Resources/Product/Resource.php now always includes:

  • averageRating (float) — maps to shop_product.average_rating
  • reviewCount (int) — maps to shop_product.review_count

Both fields appear in every product response (storefront and backend) so frontends can render star ratings on product cards from a single list call.

src/Domains/Product/Product/ListRequest.php exposes both as sortable (?sort=-averageRating) and filterable.

Migrations

Migration filePurpose
database/migrations/20260428120000_add_review_aggregates_to_shop_product.phpAdds average_rating and review_count columns with indexes
database/migrations/20260428120100_backfill_product_review_aggregates.phpOne-time UPDATE JOIN backfill via Patches\BackfillProductReviewAggregates for existing reviews

Client Extension Points

  • Legacy: Override safeAddReview() for verified-purchase check
  • REST Validator: Custom approval rules
  • Review emails: Job AdvSendMailToCustomersForReview with reviewKey (Google/Facebook/Skroutz)

Data Model

TableKey Columns
shop_product_reviewsid, product_id, customer_id, star_points, nickname, review_date, active (0/1/2), content, lang
shop_productaverage_rating DECIMAL(3,2) NOT NULL DEFAULT 0.00, review_count INT(10) UNSIGNED NOT NULL DEFAULT 0 (both added by migration 20260428120000)

average_rating holds the mean star_points across active reviews (active=1) rounded to 2 decimal places. review_count holds the count of the same active reviews. Both columns are indexed to support sort and filter operations. Products with no active reviews carry the default value of 0 for both columns.

Known Issues & Security Gaps

  1. Stub Validator — no star_points range check (src/Domains/Product/Review/Validator.php): validateForCreate() and validateForUpdate() initialize an empty $errors = [] and return without checking anything. star_points range (1–5), product_id presence, and customer_id presence are not enforced at the REST layer. A caller can create a review with star_points = 0, a negative value, or missing foreign keys.

  2. Legacy hard-delete bypasses RatingAggregator (ecommercen/eshop/models/Adv_product_reviews_model.php): this model has no delete method. A hard-delete of a review through any legacy admin path leaves shop_product.average_rating and shop_product.review_count stale because recomputeRatingAggregates() is never called.

  3. Stale RatingAggregator docblock (src/Domains/Product/Review/RatingAggregator.php:11): the class comment previously claimed "Legacy: Adv_product_reviews_model on add/update/delete" — this is inaccurate (no legacy delete path exists). The docblock has been corrected to remove "delete" but the gap in coverage remains (see item 2 above).