justinholtweb / craft-stars
Review & testimonial management for Craft CMS — star ratings, moderation, schema.org markup, and spam protection.
Package info
github.com/justinholtweb/craft-stars
Type:craft-plugin
pkg:composer/justinholtweb/craft-stars
Requires
- php: ^8.2
- craftcms/cms: ^5.0
Requires (Dev)
- codeception/codeception: ^5.0.0
- codeception/module-asserts: ^3.0.0
- codeception/module-yii2: ^1.1.0
This package is auto-updated.
Last update: 2026-07-19 20:34:58 UTC
README
A structured review system for Craft CMS with star ratings, four-state moderation, pros/cons, admin responses, spam protection, and schema.org JSON-LD markup.
Requirements
- Craft CMS 5.0+
- PHP 8.2+
Installation
composer require justinholtweb/craft-stars php craft plugin/install stars
Features
- Star ratings — configurable max (1-5 or 1-10)
- Four-state moderation — pending, approved, rejected, spam
- Pros & cons — optional structured pro/con lists per review
- Admin responses — reply to reviews from the CP with timestamp
- Spam protection — honeypot, Google reCAPTCHA v3, IP rate limiting, submission time check
- Schema.org — JSON-LD output with
Review+AggregateRatingmarkup - Email notifications — configurable recipients on new submissions
- User permissions — view, manage, moderate, respond, delete
- Bulk actions — approve, reject, mark as spam from the element index
- Craft 5 native element editor — sidebar fields, metadata, element chips
Usage
Frontend Form
<form method="post"> {{ csrfInput() }} {{ actionInput('stars/reviews/save') }} {{ redirectInput('/thank-you') }} <input type="hidden" name="entryId" value="{{ entry.id }}"> {# Honeypot (hidden from users, caught by bots) #} <input type="hidden" name="__stars_ts" value="{{ now|date('U') }}"> <div style="position:absolute;left:-9999px" aria-hidden="true"> <input type="text" name="starsHoneypot" tabindex="-1" autocomplete="off"> </div> <label for="reviewerName">Your Name</label> <input type="text" id="reviewerName" name="reviewerName" required> <label for="reviewerEmail">Email</label> <input type="email" id="reviewerEmail" name="reviewerEmail"> <label for="rating">Rating</label> <select id="rating" name="rating"> {% for i in 1..5 %} <option value="{{ i }}">{{ '★'|repeat(i) }}{{ '☆'|repeat(5 - i) }}</option> {% endfor %} </select> <label for="reviewText">Review</label> <textarea id="reviewText" name="reviewText"></textarea> <button type="submit">Submit Review</button> </form>
With Pros & Cons
<label>Pros</label> <input type="text" name="pros[]" placeholder="Pro 1"> <input type="text" name="pros[]" placeholder="Pro 2"> <label>Cons</label> <input type="text" name="cons[]" placeholder="Con 1"> <input type="text" name="cons[]" placeholder="Con 2">
AJAX Submission
const form = document.querySelector('#review-form'); form.addEventListener('submit', async (e) => { e.preventDefault(); const res = await fetch('/', { method: 'POST', headers: { 'Accept': 'application/json' }, body: new FormData(form), }); const data = await res.json(); if (data.success) { // Review submitted } else { // Handle data.error or data.errors } });
Displaying Reviews
{% set reviews = craft.reviews.forEntry(entry).all() %}
{% set avg = craft.reviews.averageRating(entry) %}
{% set count = craft.reviews.count(entry) %}
{% if count > 0 %}
<p>{{ avg|number_format(1) }} out of 5 ({{ count }} {{ count == 1 ? 'review' : 'reviews' }})</p>
{% for review in reviews %}
<article class="review">
<strong>{{ review.reviewerName }}</strong>
<span>{{ '★'|repeat(review.rating) }}{{ '☆'|repeat(5 - review.rating) }}</span>
<time datetime="{{ review.dateCreated|date('Y-m-d') }}">{{ review.dateCreated|date('M j, Y') }}</time>
{% if review.reviewText %}
<p>{{ review.reviewText }}</p>
{% endif %}
{% set pros = review.prosArray %}
{% if pros|length %}
<ul class="pros">
{% for pro in pros %}<li>{{ pro }}</li>{% endfor %}
</ul>
{% endif %}
{% set cons = review.consArray %}
{% if cons|length %}
<ul class="cons">
{% for con in cons %}<li>{{ con }}</li>{% endfor %}
</ul>
{% endif %}
{% if review.adminResponse %}
<blockquote>
<strong>Response:</strong> {{ review.adminResponse }}
</blockquote>
{% endif %}
</article>
{% endfor %}
{% endif %}
Rating Distribution
{% set dist = craft.reviews.distribution(entry) %}
{% for stars, count in dist|reverse %}
<div>{{ stars }} stars: {{ count }}</div>
{% endfor %}
Schema.org Markup
Place in your <head> to output valid JSON-LD for Google Rich Results:
{{ craft.reviews.schemaOrg(entry)|raw }}
Twig API Reference
| Method | Returns | Description |
|---|---|---|
craft.reviews.forEntry(entry) |
ReviewQuery |
Approved reviews for an entry, newest first |
craft.reviews.averageRating(entry) |
float |
Average rating (approved only) |
craft.reviews.count(entry) |
int |
Count of approved reviews |
craft.reviews.distribution(entry) |
array |
{1: n, 2: n, ...} rating histogram |
craft.reviews.schemaOrg(entry) |
string |
JSON-LD <script> tag |
All methods accept an Entry object or an entry ID integer.
Configuration
All settings are available in the CP under Stars > Settings. You can also override them in config/stars.php:
<?php return [ // Moderation 'defaultStatus' => 'pending', // 'pending' or 'approved' 'requireLogin' => false, 'allowAnonymous' => false, // Rating 'maxRating' => 5, // 1-10 // Notifications 'enableNotifications' => true, 'notificationEmails' => '', // Comma-separated, blank = system email // Anti-Spam 'enableHoneypot' => true, 'enableRecaptcha' => false, 'recaptchaSiteKey' => '$RECAPTCHA_SITE_KEY', 'recaptchaSecretKey' => '$RECAPTCHA_SECRET_KEY', 'rateLimitMinutes' => 1440, // Per IP+entry. 0 = disabled 'minSubmissionTime' => 3, // Seconds. 0 = disabled // Privacy — disable to avoid storing each piece of metadata 'captureIpAddress' => true, // Required for the per-IP rate limiter 'captureUserAgent' => true, 'captureReferrer' => true, // Schema.org 'enableSchemaOrg' => true, 'schemaItemType' => 'Product', // Product, LocalBusiness, Book, etc. // Features 'enablePros' => true, 'enableCons' => true, 'enableAdminResponse' => true, ];
Permissions
| Permission | Description |
|---|---|
stars:viewReviews |
View the Reviews section in the CP |
stars:manageReviews |
Create and edit reviews |
stars:moderateReviews |
Approve, reject, and mark as spam |
stars:respondToReviews |
Add admin responses |
stars:deleteReviews |
Delete reviews |
Admin users have all permissions by default.
Events
The plugin uses standard Craft element events. You can listen for review saves, deletes, etc.:
use craft\events\ModelEvent; use justinholtweb\stars\elements\Review; use yii\base\Event; Event::on(Review::class, Review::EVENT_AFTER_SAVE, function(ModelEvent $event) { /** @var Review $review */ $review = $event->sender; // Your logic here });
Development
The plugin ships with a test suite built on Codeception and Craft's test framework. DDEV provides PHP and a database (no local PHP install required):
ddev start ddev composer install ddev exec vendor/bin/codecept build ddev exec vendor/bin/codecept run unit
The suite boots a real Craft application, installs the plugin (running its migration), and exercises the services against a live database.
Roadmap
- Verified purchase badge
- Review voting (helpful/not helpful)
- Media attachments (photos)
- Import/export tools
- GraphQL support