codenzia/filament-reviews
Polymorphic star-rating + review system for Laravel 12 + Filament v4.
Built for the Codenzia platform stack: ships a moderation queue in the Filament admin, a Livewire-powered public form + list, anonymous submissions guarded by honeypot + per-IP rate limit, counter-cache aggregates for SEO-friendly listings, schema.org JSON-LD helpers, and first-class Filament Shield + Spatie Permission integration.
Reviews are always pending → published / rejected — there is no auto-publish. Every submission goes to the moderation queue first.
Table of contents
- Why this package
- Features
- Requirements
- Installation
- Quick start
- Moderation: who can approve
- Filament Shield integration
- Configuration reference
- Rate limiting
- Honeypot
- Counter cache
- SEO: AggregateRating JSON-LD
- Events
- Filament integration details
- Localization
- Testing
- Architecture
- FAQ
- License
Why this package
Two complementary cases drove this package:
- Eloquent hosts that already have a model (e.g. a
Game) and want users to leave star ratings + optional comments on individual records. - Config-keyed hosts with no DB model (e.g. utility tools defined in
config/tools.php, addressed by slug). These can't be polymorphically referenced by(type, id)— they need(type, slug).
Most existing review packages model only the first case. filament-reviews supports both with a single table and a unified API, and adds the production-ready bits (moderation, rate limit, honeypot, counter cache, Shield) you'd otherwise build yourself.
Features
- 1–5 stars (configurable, including half-stars and 1–10 scales)
- Optional comment with min/max length (defaults: 15–2000 chars)
- Always-moderated: every review starts
pending; a moderator promotes topublishedorrejected - Polymorphic + slug schema: review any Eloquent model OR any string-slugged subject
- Anonymous submissions allowed (configurable): name + optional/required email, never publicly displayed
- Honeypot + per-IP/per-user/per-subject rate limiting out of the box
- Counter cache on host model (
reviews_avg,reviews_count) for fast listing-page rendering; slug-only hosts get a dedicatedtool_review_aggregatestable - Filament v4 admin:
ReviewResourcewith tabbed Pending/Published/Rejected/All views, bulk approve/reject, per-row approve/reject actions, drop-inEntityReviewsRelationManagerfor any host resource - Filament Shield integration: registers
moderate_reviewsas a custom permission so it surfaces in the role-permission grid; resource CRUD permissions are auto-generated byshield:generate - Spatie Permission Gate: a single
moderate-reviewsGate gates all moderator UI, checking both the Spatie permission AND a config-driven user-ID allowlist (escape hatch for owners) - Public Livewire components:
<livewire:reviews.list>and<livewire:reviews.form>ready to drop into your show pages AggregateRatingJSON-LD helper for schema.org SEO markup- i18n out of the box (English + Arabic shipped)
- Pest test suite covering ~50 scenarios across anonymous/auth submissions, moderation, rate limiting, honeypot, slug-based reviews, comment bounds, counter cache, JSON-LD, Gate, Shield, seeder
Requirements
- PHP 8.3+
- Laravel 12+
- Filament v4 (or v5)
spatie/laravel-permission^6- (Optional)
bezhansalleh/filament-shield^4 for resource permission management UI
Installation
If you also use Filament Shield:
Then assign moderate_reviews (and the resource CRUD permissions) to your roles in the Shield UI at /admin/shield/roles.
Register the plugin in your Filament panel (typically the admin panel):
Quick start
Eloquent host (DB-backed model)
Add the trait to your host model and add the counter-cache columns to its table:
Drop the relation manager into your Filament resource:
Render reviews + form on the public show page:
That's it. Visitors can leave reviews; moderators see them at /admin/reviews.
Slug-only host (no DB row)
For hosts that have no Eloquent model — e.g. tools defined in config/toolenza.php — address them by (type, slug):
Read aggregates anywhere:
The package keeps an entry in tool_review_aggregates per (type, slug) updated on publish/unpublish, so listing pages are O(1) reads regardless of how many reviews exist.
Moderation: who can approve
Every review starts pending. To approve or reject one, a user must satisfy the moderate-reviews Gate. This Gate passes if either condition is true:
- The user holds the Spatie permission named in
config('filament-reviews.moderation.permission')(default:moderate_reviews). - The user's ID appears in
config('filament-reviews.moderation.extra_user_ids').
The second path is an escape hatch — useful for owners who haven't yet been assigned the permission, or for environments where Spatie isn't seeded. It's overridable via the REVIEWS_MODERATOR_USER_IDS env var (comma-separated IDs):
The MakeModeratorPermissionSeeder creates the moderate_reviews permission and attaches it to each role named in moderation.seed_roles. Default: super_admin, admin, moderator. Roles that don't exist are silently skipped, so the seeder is safe to run before your role seeders complete.
No auto-publish. There is intentionally no "trusted role" config that skips moderation. The moderation queue is the source of truth — even an admin's own reviews are pending until approved. In practice an admin approving their own review is a one-click action.
Filament Shield integration
When bezhansalleh/filament-shield is installed, two extras kick in automatically:
- Custom permission registration.
moderate_reviewsis appended toconfig('filament-shield.custom_permissions')at boot, so it appears in the role-permission grid right next to your resource permissions aftershield:generate. - Resource permissions via
shield:generate. Runphp artisan shield:generate --resource=ReviewResourceto populate the standardview_review,view_any_review,create_review,update_review,delete_reviewetc. permissions. These gate the normal Filament UI;moderate_reviewsgates the approve/reject actions and the relation manager.
Disable Shield integration via:
Configuration reference
The complete config/filament-reviews.php is fully commented — read it for the canonical reference. A summary of the knobs:
| Key | Default | Purpose |
|---|---|---|
review_class |
Review::class |
Swap for a subclass |
aggregate_class |
ToolReviewAggregate::class |
Slug-keyed counter cache model |
table_name |
reviews |
DB table |
aggregate_table_name |
tool_review_aggregates |
Slug-keyed aggregate table |
scale.min / scale.max |
1 / 5 |
Rating bounds |
scale.half_stars |
false |
(Reserved for future) |
min_comment_length / max_comment_length |
15 / 2000 |
Comment bounds when present |
allow_anonymous |
true |
Allow submissions without auth |
require_anonymous_email |
true |
Email required when anonymous |
moderation.permission |
moderate_reviews |
Spatie permission name |
moderation.extra_user_ids |
[] (env: REVIEWS_MODERATOR_USER_IDS) |
User-ID escape hatch allowlist |
moderation.seed_roles |
['super_admin', 'admin', 'moderator'] |
Roles to grant on seed |
shield.register_custom_permissions |
true |
Add moderate_reviews to Shield grid |
rate_limit.per_ip_per_hour |
5 |
Max reviews from one IP in 60 min |
rate_limit.per_user_per_hour |
10 |
Max reviews from one user in 60 min |
rate_limit.per_subject_per_user |
1 |
Max reviews a user can leave on the same subject |
honeypot_field |
hp |
Form-field name (hidden from humans) |
navigation.group |
Moderation |
Filament sidebar group |
counter_cache.host_columns.avg |
reviews_avg |
Host column for average |
counter_cache.host_columns.count |
reviews_count |
Host column for count |
Rate limiting
Three independent limits run on every submission:
per_ip_per_hour— cap the number of reviews from any one IP in a rolling 60-min window.per_user_per_hour— cap per authenticated user (in addition to IP).per_subject_per_user— cap how many reviews one user can leave on the same subject;1means one review per(user, reviewable), which is the usual product expectation.
Set any limit to 0 to disable it.
When a limit is hit, the ReviewSubmissionService throws RateLimitExceededException. The Livewire form catches it and shows the message inline — the user's input is preserved (not reset) so they don't lose what they typed.
For non-Livewire endpoints, a review.throttle route middleware is registered (Codenzia\FilamentReviews\Http\Middleware\ThrottleReviews) that returns HTTP 429 once per_ip_per_hour is exceeded.
Honeypot
The form renders a hidden input named after config('filament-reviews.honeypot_field') (default hp). Humans don't see it; naïve bots fill every field they find. On submit:
- Empty honeypot → proceed normally.
- Non-empty honeypot →
ReviewSubmissionServicethrowsHoneypotTriggeredException. The Livewire form catches it and fakes success —submitted = true, no database write, no event dispatched. The bot learns nothing about its failure.
Counter cache
Aggregate reads (rendering "★ 4.3 (172 reviews)" on a listing page) happen on every page load. Computing AVG() / COUNT() per card kills query budgets fast. The package maintains a counter cache automatically:
- Eloquent hosts with the
HasReviewstrait get theirreviews_avg+reviews_countcolumns updated by theReviewObserverwhenever a review's status crosses thepublishedboundary in either direction. You must add those columns to the host table in your own migration. - Slug-only hosts get a row in
tool_review_aggregatesper(reviewable_type, reviewable_slug), upserted on the same events.
The trait's averageRating() and reviewsCount() methods read these columns when present, falling back to a live AVG/COUNT query only when the columns don't exist yet (e.g. host migration not yet run).
SEO: AggregateRating JSON-LD
Include schema.org AggregateRating markup on your subject pages — it tends to enable star-rating rich results in Google search.
The helper returns the full @type: AggregateRating shape, ready to drop into a host node like VideoGame, Product, WebApplication, etc.
Events
Three events are dispatched at the obvious moments:
| Event | When |
|---|---|
ReviewSubmitted |
Anyone (auth or anonymous) successfully posts a review |
ReviewPublished |
A moderator approves a review (status transitions to published) |
ReviewRejected |
A moderator rejects a review |
Each event carries the Review model. Hook them up to send emails, ping Slack, refresh sitemaps — whatever your app needs.
Filament integration details
ReviewResource
Lives in the Filament panel where FilamentReviewsPlugin::make() is registered. Sidebar appears under Moderation by default (configurable).
- List page with tabs: Pending / Published / Rejected / All
- Per-tab badge counts
- Filters: status, rating
- Row actions: Approve, Reject (with optional reason), Edit
- Bulk actions: Approve, Reject (with optional reason), Delete
- All actions are gated by the
moderate-reviewsGate — non-moderators see nothing in the sidebar.
EntityReviewsRelationManager
A drop-in relation manager so any host resource (e.g. GameResource) can show reviews inline:
Uses the host's reviews() morph relation defined by the HasReviews trait.
Public components
Localization
English (en) and Arabic (ar) translation files ship with the package. Publish them with:
…then edit resources/lang/vendor/filament-reviews/{locale}/messages.php. The Arabic file is RTL-aware and uses correct dual/plural forms.
Testing
The package ships a Pest test suite with around 50 scenarios:
Coverage includes:
- Anonymous submissions (allowed; rejected when disabled; require name + email; honor
require_anonymous_email = false) - Authenticated submissions (no auto-publish even for admins; name/email not required)
- Moderation flow (approve transitions, reject with reason, idempotent re-approve, host counter cache updates, slug counter cache upsert, delete handling)
- Rate limiting (per IP, per user, per subject; configurable; disabled when
0) - Honeypot (silent reject; allow when empty)
- Comment length (min, exact min, max, exact max, null, whitespace-only normalization)
- Slug-based reviews (persistence, aggregates ignore pending rows, breakdown, per-subject rate limit, fallback to live query when no aggregate row exists)
- JSON-LD helper (null when empty, schema.org shape for model and slug, custom scale bounds)
- Moderator Gate (Spatie permission grants, role-via-permission grants, allowlist grants, string-ID allowlist match, denial)
ReviewResourceaccess control- Shield integration (custom permission registration, opt-out flag, no-Shield environment safety)
- Seeder (creates permission, assigns to configured roles, idempotent, silent on missing roles)
- Livewire ReviewForm + ReviewList components (anonymous submit, honeypot silent fail, rate-limit error surfacing, validation errors, slug-only targets, summary/breakdown/pagination)
ReviewableTarget(construction validation, isModel/isSlug, scoped queries, live-aggregate fallback)ReviewStatusenum (labels, colors, value roundtrip)
Architecture
FAQ
Why no auto-publish for trusted roles? Because "trusted" is an ill-defined gradient that drifts as your team grows. Keeping the queue as the single source of truth means there's exactly one place to look at every review before it goes public. Approving your own review is one click; the value of consistent moderation is worth the click.
Can I implement my own auto-publish?
Yes — listen to ReviewSubmitted and immediately call $event->review->approve() if your custom logic decides the user is trusted. The package doesn't ship the rule; it ships the hook.
My host model is a MorphMany to multiple review types — does that conflict?
No. HasReviews::reviews() defines a relation called reviews. As long as no other trait on the same model declares reviews(), you're fine. The relation is morphed on reviewable_type / reviewable_id, so different hosts can coexist in the same reviews table without collision.
Why store anonymous email in the database if you never display it?
For moderators to follow up on questionable reviews ("we got a spam wave from this address") and for soft duplicate detection. Treat it like any other PII — covered by your privacy policy. Set require_anonymous_email = false to opt out.
Will reviews fall through when Shield is uninstalled?
The Gate works without Shield — it only needs Spatie Permission. Shield just provides the UI to manage the permissions Spatie tracks. Without Shield you can assign moderate_reviews to roles in your own seeders or via a Bouncer-style UI.
Half-stars / 1-10 scale?
Set scale.min and scale.max in config; the form renders the right number of star buttons. scale.half_stars is reserved for a future release that adds a separate half-step input.
License
MIT (or proprietary — see LICENSE.md).