Build a custom reviews provider for a non-WooCommerce source

Goal

By default, mam-reviews-manager reads and writes reviews as WordPress comments. Sibling plugins extend this — GeoDirectory listings and WooCommerce products each contribute their own provider so the app’s reviews come from the same native source the website reads from. This guide shows you how to do the same for any other source: implement the reviews provider contract, register your provider, and have the Reviews Manager resolve it for the post types you own.

When you finish, reviews for the post types your provider claims will flow end to end — the app’s reviews header, the reviews list, the “my review” lookup, the All Reviews list, and review submission — without touching core files in mam-reviews-manager.

A note on naming. This guide is titled “non-WooCommerce” because WooCommerce products are already handled by their own provider. The same steps apply to any source that is not plain WordPress comments: a third-party listings directory, a headless CMS, an external ratings API mirrored into WordPress, and so on.


How the provider layer works

Core never reads or writes review storage directly. For every post, it resolves exactly one provider through a registry and delegates all reads and writes to it. That single rule is what guarantees the app and the website stay in sync.

Resolution works like this:

  • Providers are collected from the mam_reviews_providers filter.
  • Each provider declares a priority. The registry sorts them highest-priority-first.
  • For a given post, the registry walks the sorted list and picks the first provider whose supports() returns true.
  • The built-in WordPress-comments provider registers at priority 0 and supports everything, so resolution always yields a provider. Your provider wins by registering at a higher priority and supporting only the posts it owns.

Your job is therefore narrow: tell the registry which posts you own (supports()), and answer the read/write methods for those posts.


Prerequisites

  • MAM Suite installed with mam-main and mam-reviews-manager active. The provider layer ships in mam-reviews-manager 26.23.0+.
  • A plugin of your own to host the provider (a sibling MAM plugin, or any standard WordPress plugin loaded by plugins_loaded).
  • PHP 8.1+ — the contract uses typed signatures.
  • Comfort reading the reference implementation. The single best example is the default provider:
    • Interface: mam-reviews-manager/includes/interface-mam-reviews-provider.php
    • Shared base: mam-reviews-manager/includes/abstract-mam-reviews-provider-base.php
    • Default provider: mam-reviews-manager/includes/class-mam-reviews-default-provider.php
    • Registry + resolver: mam-reviews-manager/includes/class-mam-reviews-provider-registry.php

The data shapes each method returns are defined in the contract spec referenced from the interface docblock (claude-tasks/mam-reviews-refactoring/00-contract-spec.md, sections §4.1–§4.3 and §7). Where this guide describes a shape generically, defer to that spec and to the default provider’s output for the exact keys.


The contract at a glance

A provider implements MAM_Reviews_Provider. The seven methods, grouped by what they do:

Method Returns Used for
supports( int $post_id, string $post_type ): bool bool Claiming ownership of a post
get_criteria( int $post_id ): array list of criterion descriptors The rating inputs on the add-review form
get_aggregates( int $post_id ): array aggregate stats The reviews summary header
get_reviews( int $post_id, array $args = [] ): array list of normalized reviews The reviews list
get_user_review( int $post_id, int $user_id ): ?array one normalized review or null The “you’ve already reviewed” / edit path
save_review( array $submission ): array { ok, comment_id, message } Persisting a submission
get_all_reviews( array $args = [] ): array map of post_id => reviews The geo-filtered All Reviews list

You will almost never implement all seven from scratch. Extend MAM_Reviews_Provider_Base — the abstract base supplies storage-agnostic helpers and sensible defaults for several methods.

What the base gives you for free:

  • get_criteria() returns a single “overall” criterion. Override only if your source is multi-criteria (like GeoDirectory’s Cleanliness / Food Quality dynamics).
  • overall_criterion() builds that criterion from the mam_reviews_rating_type option (stars vs numeric).
  • format_reviewer_name() renders “First L” from a WordPress user.
  • load_photos() loads review photos from attachment_id comment meta.
  • build_review() assembles the normalized review shape — including the legacy stars alias the current apps still read (the matching aggregate-level average_rating alias comes from aggregate()) — so your output stays behavior-preserving.
  • aggregate() computes average, total, star distribution, and latest-review date from a list of normalized reviews.
  • get_all_reviews() has a default loop that calls your get_reviews( 0, ... ). Override it if your source can answer “all posts I own” in one efficient query.
  • supports() defaults to true — you must override this, or you will claim every post.

Steps

1. Create the provider class

Create a class that extends the base. Put it in your own plugin’s includes/. At minimum, override supports() and implement the read/write methods the base does not.

<?php
/**
 * Reviews provider backed by <your source>.
 * Layer: Feature
 */
class my_source_reviews_provider extends MAM_Reviews_Provider_Base {

    /**
     * Claim only the post types this source owns.
     */
    public function supports( int $post_id, string $post_type ): bool {
        return in_array( $post_type, array( 'my_listing_cpt' ), true );
    }

    // ... read/write methods below ...
}

Match the base only on supports() if your source stores ratings as WordPress comments and you simply need to claim a different post type. If your data lives elsewhere, you will override the read/write methods too.

2. Implement the read methods

These power what the user sees. Return the normalized shapes — do not invent your own keys, because the app and the All Reviews list key off the contract.

  • get_reviews( $post_id, $args ) — return a list of normalized reviews for the post. When $post_id is 0, return site-wide reviews, each carrying its own post_id (this is what the default get_all_reviews() loop relies on). Skip anonymous entries the way the default provider skips comments with user_id <= 0.

  • get_aggregates( $post_id ) — return overall_average, total_ratings, distribution, per_criterion, latest_review_date, and display_type. If your source already maintains native aggregates, read those. If not, the simplest correct implementation is to compute them from your own review list:

    public function get_aggregates( int $post_id ): array {
        return $this->aggregate( $this->get_reviews( $post_id ) );
    }
  • get_user_review( $post_id, $user_id ) — return the user’s existing review (normalized) or null. Guard against non-positive ids.

  • get_criteria( $post_id ) — only override if multi-criteria. Otherwise inherit the single overall criterion.

To build each normalized review from a WP_Comment, lean on the base’s build_review( $comment, $overall, $ratings, $current_user ) rather than hand-assembling the array. If your source is not comment-backed, you’ll construct the normalized array yourself — copy the key set the base emits so nothing downstream breaks.

3. Implement save_review() — write through the native path

This is the most important method to get right. The submission arrives normalized (see spec §7): post_id, post_type, user (a WP_User), review_text, ratings (criterion id => value, including MAM_Reviews_Provider_Base::OVERALL), photos (attachment ids), tip_amount, tip_level, and is_approved.

The contract’s rule: write so that your source’s native aggregates update. The default provider has no native aggregate table, so it inserts a comment directly and stores the overall rating in rating comment meta. Providers backed by a native engine must instead write through that engine’s own save path (for example, wp_new_comment() so the comment_post hook fires) — otherwise the website’s rating counts and the app’s aggregates will drift apart.

Return the result shape exactly:

return array(
    'ok'         => true,        // false on failure
    'comment_id' => (int) $id,   // or null on failure
    'message'    => '',          // user-facing error text on failure
);

Honor is_approved for the moderation/auto-approve state, and persist each entry in photos the way the default provider does (one attachment_id meta row per photo, so load_photos() can read them back).

4. Decide on get_all_reviews()

The base default calls get_reviews( 0, $args ) and buckets the results by post_id. That is correct as long as your get_reviews( 0 ) returns only the posts your provider owns. When it assembles the All Reviews list, the registry walks providers highest-priority-first and gives each post to the first provider that yields it, dropping any later provider’s reviews for that same post. So a higher-priority provider that returns posts it doesn’t actually own will shadow the true owner’s reviews for those posts. If a single query over your source is cheaper, override get_all_reviews() directly.

5. Register the provider on the mam_reviews_providers filter

Add a descriptor keyed by a unique slug. Give it a priority above 0 so it outranks the default fallback, and below any provider you must not displace.

add_filter( 'mam_reviews_providers', function ( array $providers ): array {
    $providers['my_source'] = array(
        'label'    => __( 'My Source', 'your-textdomain' ),
        'class'    => 'my_source_reviews_provider',
        'priority' => 20,
    );
    return $providers;
} );

The registry instantiates class once (it must class_exists and implement the interface), sorts by descending priority, and resolves the first provider whose supports() returns true. The default WordPress-comments provider sits at priority 0, so any positive priority wins for the post types you claim.

Load order matters. The registry boots lazily on first resolution, so your provider’s class file must be required and the filter added before the first review request. Registering inside your plugin’s plugins_loaded bootstrap (as mam-reviews-manager does for its own default provider) is the safe pattern.

6. Verify resolution

Confirm the registry hands your provider the posts you expect. The global resolver is mam_reviews_provider_for( int $post_id ):

$provider = mam_reviews_provider_for( $my_post_id );
// Expect: instanceof my_source_reviews_provider for your post types,
//         instanceof mam_reviews_default_provider for everything else.

Then exercise the app paths: open a reviewed item, check the header aggregates and the reviews list, submit a review, and confirm it appears on both the website’s native source and the app (this is the whole point — one source of truth). Finally, open the All Reviews list and confirm your posts appear without duplication.


Common pitfalls

  • Forgetting to override supports(). The base returns true, so an un-narrowed provider at a high priority will hijack reviews for every post type, including WooCommerce and GeoDirectory.
  • Inventing keys. The normalized review and aggregate shapes are a contract the app and the All Reviews list depend on, including legacy aliases like stars and average_rating. Use the base helpers so you emit the full superset.
  • Writing a bare comment when your source has native aggregates. Reviews will display but native rating counts won’t update, and the app and website will disagree.
  • Returning all posts from get_reviews( 0 ). Only return the posts your provider owns. Because the registry gives each post to its highest-priority provider, a higher-priority provider that yields posts it doesn’t own will shadow the real owner’s reviews in the All Reviews list.
  • Registering too late. Add the filter before the first resolution.

  • Plugin Reference: mam-reviews-manager — feature overview, content sections, and the AJAX submission path.
  • Explanation: How MAM resolves one provider per post — the priority + supports() resolution model in depth.
  • Reference: The reviews provider contract — the exact normalized shapes for criteria, aggregates, reviews, and submissions (spec §4–§7).

Verification

This article was last verified against:

  • Plugin: mam-reviews-manager (provider layer, @since 26.23.0)
  • Source: includes/interface-mam-reviews-provider.php
  • Source: includes/abstract-mam-reviews-provider-base.php
  • Source: includes/class-mam-reviews-provider-registry.php
  • Source: includes/class-mam-reviews-default-provider.php
  • Source: mam-reviews-manager.php (the mam_reviews_providers registration of the default provider)

Re-verify whenever the interface method signatures change, the normalized data shapes change, the mam_reviews_providers descriptor format changes, or the registry’s priority/resolution rules change.

Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!