Search by

justinholtweb / craft-stars

justinholtweb

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

Statistics

Installs: 34

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

5.1.0 2026-09-17 16:50 UTC

This package is auto-updated.

Last update: 2026-09-17 16:51:47 UTC


README

A structured reviews and comments system for Craft CMS: star ratings, threaded comments, four-state moderation, pros/cons, admin responses, a submitter blocklist, pluggable captcha, 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)
  • Threaded comments — a full comment system on entries with configurable reply depth
  • Four-state moderation — pending, approved, rejected, spam (reviews and comments)
  • Pros & cons — optional structured pro/con lists per review
  • Admin responses — reply to reviews from the CP with timestamp
  • Blocklist — block submitters by email, IP, or user id
  • Pluggable captcha — reCAPTCHA v3, reCAPTCHA v2, hCaptcha, or Cloudflare Turnstile
  • Spam protection — honeypot, per-IP rate limiting, submission time check
  • Login gating — optionally require login (and auto-fill author details)
  • Schema.org — JSON-LD output with Review + AggregateRating markup
  • Email notifications — new submissions to moderators, replies to comment authors
  • User permissions — separate permission tiers for reviews, comments, and the blocklist
  • Bulk actions — approve, reject, mark as spam, block author
  • 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 }}">{{ '★★★★★'|slice(0, i) }}{{ '☆☆☆☆☆'|slice(0, 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.stars.reviews.forEntry(entry).all() %}
{% set avg = craft.stars.reviews.averageRating(entry) %}
{% set count = craft.stars.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>{{ '★★★★★'|slice(0, review.rating) }}{{ '☆☆☆☆☆'|slice(0, 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.stars.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.stars.reviews.schemaOrg(entry)|raw }}

Twig API Reference

Method Returns Description
craft.stars.reviews.forEntry(entry) ReviewQuery Approved reviews for an entry, newest first
craft.stars.reviews.averageRating(entry) float Average rating (approved only)
craft.stars.reviews.count(entry) int Count of approved reviews
craft.stars.reviews.distribution(entry) array {1: n, 2: n, ...} rating histogram
craft.stars.reviews.schemaOrg(entry) string JSON-LD <script> tag

All methods accept an Entry object or an entry ID integer.

Deprecated top-level variables

Before 5.1.0 the plugin registered craft.reviews and craft.comments. craft.comments is also used by verbb/comments, so with both plugins installed one silently replaced the other. Everything now lives under craft.stars.

The old names still work for existing templates, but only when no other plugin has claimed them — if verbb/comments is installed, it keeps craft.comments and Stars stays out of the way. Move to craft.stars.reviews / craft.stars.comments; the aliases will be removed in 6.0.0.

Comments

Stars also provides a threaded comment system on entries, with the same moderation, spam protection, and login-gating as reviews.

Comment Form

<form method="post">
    {{ csrfInput() }}
    {{ actionInput('stars/comments/save') }}
    {{ redirectInput('') }}
    <input type="hidden" name="entryId" value="{{ entry.id }}">
    {# For a reply, include the parent comment's id: #}
    {# <input type="hidden" name="parentId" value="{{ parentComment.id }}"> #}
    <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>

    {# Name/email are only used for guests; logged-in users are filled in automatically. #}
    <label for="authorName">Name</label>
    <input type="text" id="authorName" name="authorName">

    <label for="body">Comment</label>
    <textarea id="body" name="body" required></textarea>

    <button type="submit">Post Comment</button>
</form>

Displaying Comments

The simplest way to render a full nested thread is the bundled recursive macro, fed by craft.stars.comments.tree(entry):

{% import 'stars/_comments/thread' as commentThread %}
{{ commentThread.thread(craft.stars.comments.tree(entry)) }}

Or build it yourself — each comment in the tree exposes its replies via .children:

{% for comment in craft.stars.comments.tree(entry) %}
    <article class="comment">
        <strong>{{ comment.authorName }}</strong>
        <p>{{ comment.body }}</p>

        {% for reply in comment.children %}
            <article class="comment comment--reply">
                <strong>{{ reply.authorName }}</strong>
                <p>{{ reply.body }}</p>
            </article>
        {% endfor %}
    </article>
{% endfor %}

Reply nesting is capped by the Max Comment Depth setting; deeper replies are automatically attached at the deepest allowed level.

craft.stars.comments API

Method Returns Description
craft.stars.comments.tree(entry) Comment[] Approved comments as a nested tree (replies on .children)
craft.stars.comments.forEntry(entry) CommentQuery Approved comments for an entry, oldest first
craft.stars.comments.topLevel(entry) CommentQuery Approved top-level comments (no replies)
craft.stars.comments.replies(comment) array Approved replies to a comment
craft.stars.comments.count(entry) int Count of approved comments

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,

    // Comments
    'enableComments' => true,
    'commentsRequireLogin' => false,
    'commentsAllowAnonymous' => false,
    'maxCommentDepth' => 2,             // 1 = no replies, 2 = one level, ...
];

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