Skip to content

Reviews and ratings

Client reviews are stored as a private review post type and surfaced two ways: as a carousel with a headline satisfaction percentage, and as the aggregateRating inside the LegalService structured data on service pages.

Solicitor selection is trust-driven, so review proof sits on the pages that convert. Reviews are held locally (rather than pulled from Trustpilot or ReviewSolicitors) so marketing controls which ones appear and in what order — the external profiles are only referenced as sameAs URLs in the schema (inc/seo.php:208-216).

The satisfaction percentage is a manually entered number, not calculated from the review posts.

Path What it does
wp-content/themes/bpcollins/inc/cpt.php:264 review post type — public => false, show_ui => true
wp-content/themes/bpcollins/blocks/reviews-carousel/block-reviews-carousel.php The carousel: percentage, copy, and up to 12 reviews
wp-content/themes/bpcollins/inc/classes/BPReview.php Accessor: title, quote (post content), star_rating, name
wp-content/themes/bpcollins/inc/seo.php:147-225 Review selection and aggregateRating calculation for LegalService
wp-content/themes/bpcollins/single-service.php (~line 795) Sets $selected_reviews from the page’s page_reviews field before requiring the carousel

Content model. A review post holds the reviewer’s headline as the post title, the review body as post content, and two ACF fields: star_rating and name. It is non-public — no permalink, no archive; it exists only to be embedded. There is a separate, largely unused testimonial post type (inc/cpt.php:302) supporting title only.

Selection precedence — identical logic in the carousel (block-reviews-carousel.php:10-38) and in the schema generator (inc/seo.php:151-179), in this order:

  1. $selected_reviews (the page’s own page_reviews field) → query those IDs, ordered by menu_order.
  2. Otherwise, if the global rc_latest_reviews flag is set → query the 12 most recent by menu_order, then shuffle() them so the order varies per request.
  3. Otherwise rc_featured_reviews → use that curated ID list directly.

Carousel display. Percentage and intro copy come from the block’s own fields with a fallback to the global options rc_review_percentage and rc_review_copy (block-reviews-carousel.php:5-8). Each slide renders the title with a trailing quote mark, the body wrapped in quotes, one <span> per whole star, and the reviewer name. Swiper drives the carousel.

Structured data. ServiceSchema::generate() averages star_rating across the selected reviews and adds aggregateRating with ratingValue (the mean), bestRating (the highest single rating seen) and reviewCount (inc/seo.php:181-225). This only runs on service singles — sector, team and page templates get no aggregate rating.

On a service page, disable_review_carousel hides the carousel; the schema is not affected by that flag, so a page can carry an aggregate rating with no visible reviews.

Global Options ACF page: rc_review_percentage, rc_review_copy, rc_latest_reviews, rc_featured_reviews.

Per-page/block: page_reviews (service posts), review_percentage, copy, latest_reviews, featured_reviews (block fields), disable_review_carousel (service posts).

Per-review: star_rating, name.

  • $selected_reviews is an inherited variable, not a parameter. The carousel template reads a variable set by whichever template required it (single-service.php). Used as a Gutenberg block on a normal page, $selected_reviews is undefined and selection falls through to the global settings. That is by design but it is invisible from the block’s own code.
  • aggregateRating has no zero guard. inc/seo.php:191 divides by $reviewCount before the if ($reviewCount > 0) check, and $reviews is undefined if none of the three sources are set — so a service page with no review configuration at all emits PHP warnings during schema generation.
  • bestRating is compared as a string (inc/seo.php:187), so ratings are ordered lexically. With single-digit ratings this is harmless; a “10” would sort below “5”.
  • ratingValue is the raw mean, unrounded — it can serialise as a long float.
  • Two review sources can disagree with the carousel. Because rc_latest_reviews shuffles, the carousel order changes per request while the schema recomputes its own average from its own shuffled set. The numbers agree; the ordering does not.
  • disable_review_carousel does not disable the schema. Aggregate rating claims are regulated marketing content — if the intent is “no reviews on this page”, the schema needs suppressing too.
  • BPReview::$post_type says 'post' (BPReview.php:4). It is unused, but it is misleading.
  • blocks/reviews-tabs/ is an unimplemented stub. It still contains the generator boilerplate (block-example classes, render_heading/render_btn calls) and renders nothing meaningful. Do not treat it as a working block.
  • Changing selection logic → change it in both block-reviews-carousel.php and inc/seo.php, or the visible reviews and the schema will describe different sets.
  • Adding a review field → add the ACF field, expose it on BPReview, then use it in the carousel. Schema changes need the field reading in inc/seo.php too.
  • Adding a Review schema piece (individual reviews rather than an aggregate) → new Abstract_Schema_Piece subclass plus a wpseo_schema_graph_pieces filter, following PersonSchema/ServiceSchema in inc/seo.php.
  • Fixing the divide-by-zero → initialise $reviews = array() before the precedence chain and move the average inside the $reviewCount > 0 branch.
  • Rebuild assets with gulp in wp-content/themes/bpcollins after touching the carousel JS/SCSS.
  • Verify: a service page with page_reviews set, one without, and one with disable_review_carousel on — check both the rendered carousel and "aggregateRating" in the page source.

None. No automated tests exist in this repository.