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.
Why it exists
Section titled “Why it exists”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.
Entry points
Section titled “Entry points”| 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 |
How it works
Section titled “How it works”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:
$selected_reviews(the page’s ownpage_reviewsfield) → query those IDs, ordered bymenu_order.- Otherwise, if the global
rc_latest_reviewsflag is set → query the 12 most recent bymenu_order, thenshuffle()them so the order varies per request. - 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.
Configuration
Section titled “Configuration”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.
Invariants and gotchas
Section titled “Invariants and gotchas”$selected_reviewsis an inherited variable, not a parameter. The carousel template reads a variable set by whichever templaterequired it (single-service.php). Used as a Gutenberg block on a normal page,$selected_reviewsis undefined and selection falls through to the global settings. That is by design but it is invisible from the block’s own code.aggregateRatinghas no zero guard.inc/seo.php:191divides by$reviewCountbefore theif ($reviewCount > 0)check, and$reviewsis 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.bestRatingis 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”.ratingValueis the raw mean, unrounded — it can serialise as a long float.- Two review sources can disagree with the carousel. Because
rc_latest_reviewsshuffles, 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_carouseldoes 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_typesays'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-exampleclasses,render_heading/render_btncalls) and renders nothing meaningful. Do not treat it as a working block.
Changing it safely
Section titled “Changing it safely”- Changing selection logic → change it in both
block-reviews-carousel.phpandinc/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 ininc/seo.phptoo. - Adding a
Reviewschema piece (individual reviews rather than an aggregate) → newAbstract_Schema_Piecesubclass plus awpseo_schema_graph_piecesfilter, followingPersonSchema/ServiceSchemaininc/seo.php. - Fixing the divide-by-zero → initialise
$reviews = array()before the precedence chain and move the average inside the$reviewCount > 0branch. - Rebuild assets with gulp in
wp-content/themes/bpcollinsafter touching the carousel JS/SCSS. - Verify: a service page with
page_reviewsset, one without, and one withdisable_review_carouselon — check both the rendered carousel and"aggregateRating"in the page source.
None. No automated tests exist in this repository.