Aller au contenu

omnisense/omnireview

Omnireview: what customers say of an establishment elsewhere - its rating, its reviews, replying as the establishment, inviting a customer to review, the link to leave one - whoever hosts them: Google (omnireview/google), Tripadvisor (omnireview/tripadvisor), Trustpilot (omnireview/trustpilot), each platform's terms of display and caching declared; and its Symfony and Twig bridges, which always show the origin, the date and the link.

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnireview:1.x-dev

Omnireview is one PHP contract for what customers say of an establishment on platforms it does not run - its rating, its reviews, replying to a review as the establishment, inviting a customer to leave one, the link to write one - whoever hosts them: Google, Tripadvisor, Trustpilot. The application type-hints Omnireview\Registry (the configured platforms by name) or one of the capability interfaces, and reads neutral models:

$place = new Place('ChIJLU7jZClu5kcR4PcOOO6p3I0');                 // the platform's identifier, kept by the application

$rating = $registry->rating('google')->rating($place);
$rating->value; $rating->count; $rating->url;                      // 4.6, 128, the place on Google Maps

foreach ($registry->reviews('google')->reviews($place, 3) as $review) {
    $review->author; $review->publishedAt; $review->url;           // shown with every review
}
$registry->writeUrl('google')->writeUrl($place);                   // where a customer writes one
$registry->get('google')->terms();                                 // how long it may be kept, how it must be credited

Each platform is a provider package (omnireview/google, omnireview/tripadvisor, omnireview/trustpilot) with a factory that builds a gateway from options. A gateway reads and writes on its platform and keeps nothing; it declares the platform's terms (Terms) - how long what it gives may be kept, how many reviews one call gives, what must be shown with it, whether a page that shows it must stay out of search engines - read in each platform's documentation, with the address they were read at. The core requires PHP 8.2 and symfony/http-client-contracts, nothing of a framework; it ships a Symfony bundle and a Twig extension, whose components the application brings.

When to use it

  • A site that shows its Google, Tripadvisor or Trustpilot rating and a few reviews, credited to the platform as the platform requires, and links to the platform to read them all and to write one.
  • A back office that answers reviews as the establishment (Google Business Profile, Trustpilot) or invites customers after a purchase (Trustpilot), through one interface per action.
  • Code that must work with whichever platform a customer of the application uses: the configuration names the platform, the code asks the registry for a capability.

It is not a review store: nothing is collected, kept or rewritten here, and no JSON-LD AggregateRating is built from a third party's reviews (Google's review snippet guidelines exclude them). It is not a way to pick which customers to invite: Google and Trustpilot forbid soliciting only positive reviews.

Providers

Three packages. "Gives" is what each gateway's capabilities() answers with the options named; "Kept" is its Terms::$cacheFor; "Reviews" its Terms::$maxReviews. Read from the code at this commit and printed by the harness's bare script.

Package Factory name Gives With the owner's access Kept Reviews per call Pages not indexed
omnireview/google google rating, reviews, write-url (Places API, api_key) reply, and up to 50 reviews (Business Profile API: client_id, client_secret, refresh_token) never (0): the place ID only 5 declared; 50 from the Business Profile no
omnireview/tripadvisor tripadvisor rating, reviews, write-url (Content API, api_key) - as Tripadvisor's Caching Policy allows (null: not public, so not kept) 5 yes
omnireview/trustpilot trustpilot rating, reviews, write-url (api_key) reply, invite (api_secret, business_user_id) 24 hours (86400) 100 no

No platform has been called live by these packages: no key was at hand (each provider's "Verified, and not"). Google's link to write a review is the one address checked, on 2026-10-08.

How this manual is organised

  • Installation: requirements, a first script in plain PHP and its output.
  • Symfony and Twig: the bundle, its configuration tree, its services, the Twig functions and the widget.
  • Laravel: a service provider written by hand (there is no Laravel bridge).
  • Concepts and models: the contract interface by interface, every model and field.
  • Usage: rating, reviews, replying, inviting, the write link, the terms - per capability, with what each platform does.
  • Configuration reference: the registry, Config, every option of every factory, the bundle's keys.
  • Errors: the exceptions, when each is thrown, what to catch.
  • Testing: testing code that uses the family, the family's own tests.
  • The Docker harness: bare PHP, the console, the tests.
  • Writing a provider.
  • Verified, and not.

Proposer une modification docs/index.md

Installation

composer require omnisense/omnireview omnireview/google
composer require omnireview/tripadvisor        # each platform the application shows
composer require omnireview/trustpilot

Requirements

  • PHP 8.2 or later.
  • omnisense/omnireview requires symfony/http-client-contracts (^3.0) and nothing else: no framework, no HTTP client of its own. It replaces omniscient/omnireview and glitchr/omnireview: a composer.json that still requires an old name resolves to this package.
  • Each provider requires omnisense/omnireview and symfony/http-client (6.4, 7 or 8). Its factory takes an HttpClientInterface (the application's, a MockHttpClient in a test) and makes its own with HttpClient::create() when given none.
  • The Symfony bundle needs symfony/config, symfony/dependency-injection and symfony/http-kernel; the Twig functions twig/twig, and symfony/cache-contracts to keep what they show. None is required by the package: the application has them (Symfony and Twig).

A first script

A whole script, in plain PHP - no framework, no container. So that it runs without keys, the HTTP client answers from the providers' recorded answers (the harness's config/recorded.php, a MockHttpClient callback serving each provider's Tests/Fixtures); with keys, give the factories Symfony\Component\HttpClient\HttpClient::create() instead.

<?php

require __DIR__.'/vendor/autoload.php';

use Omnireview\Exception\NotSupportedException;
use Omnireview\Google\GoogleGatewayFactory;
use Omnireview\Model\Place;
use Omnireview\Registry;
use Omnireview\Trustpilot\TrustpilotGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;

// The platforms' recorded answers, as the harness serves them: nothing leaves the machine.
$http = new MockHttpClient(require __DIR__.'/vendor/omnisense/omnireview/docker/harness/config/recorded.php');

$registry = new Registry([new GoogleGatewayFactory($http), new TrustpilotGatewayFactory($http)], [
    'google' => ['factory' => 'google', 'options' => ['api_key' => getenv('GOOGLE_PLACES_KEY') ?: 'example', 'language' => 'fr']],
    'trustpilot' => ['factory' => 'trustpilot', 'options' => ['api_key' => getenv('TRUSTPILOT_API_KEY') ?: 'example']],
]);

$place = new Place('ChIJLU7jZClu5kcR4PcOOO6p3I0');           // a Google place ID: the one thing Google lets you keep
$rating = $registry->rating('google')->rating($place);
printf("%s/%d from %d reviews - %s\n", $rating->value, $rating->scale, $rating->count, $rating->url);
foreach ($registry->reviews('google')->reviews($place, 3) as $review) {
    printf("  %s by %s on %s%s: %s\n", $review->rating, $review->author, $review->publishedAt?->format('Y-m-d'), $review->translated ? ' (translated by Google)' : '', $review->text);
}
echo 'to write one: ', $registry->writeUrl('google')->writeUrl($place), "\n";

$terms = $registry->get('google')->terms();
printf("kept: %s; credited to %s; read at %s\n", 0 === $terms->cacheFor ? 'never' : $terms->cacheFor.' s', $terms->attribution, $terms->source);

$unit = $registry->get('trustpilot')->find('www.maison-erable.example');   // TrustpilotGateway's own: once, then keep the ID
echo 'trustpilot business unit: ', $unit->id, ' (', $unit->name, ")\n";
echo 'trustpilot gives: ', implode(', ', $registry->get('trustpilot')->capabilities()->gives()), "\n";

try {
    $registry->invitations('google');                          // Google has no invitation API
} catch (NotSupportedException $e) {
    echo $e->getMessage(), "\n";
}
$ php first.php
4.6/5 from 128 reviews - https://maps.google.com/?cid=10223305813012340704
  5 by Camille R. on 2026-09-23: Un accueil chaleureux, une cuisine remarquable.
  4 by Jordan K. on 2026-09-01 (translated by Google): Très bon, un peu d'attente.
to write one: https://search.google.com/local/writereview?placeid=ChIJLU7jZClu5kcR4PcOOO6p3I0
kept: never; credited to Google Maps; read at https://developers.google.com/maps/documentation/places/web-service/policies
trustpilot business unit: 46d6a890000064000500e0c3 (Maison Érable)
trustpilot gives: rating, reviews, write-url
The "google" gateway (Google) does not give inviting a customer to review; it gives: rating, reviews, write-url.

(run on 2026-10-26 with the harness's image, PHP 8.4, the workspace's packages mounted read-only and no network)

The pieces:

  • a factory per provider package, which takes the application's HTTP client;
  • the registry, built from the factories and each gateway's options by name; its rating(), reviews(), replies(), invitations() and writeUrl() give the gateway typed by what is asked of it, or throw NotSupportedException when it does not implement that interface;
  • the gateway it gives, built once, the first time it is asked for (Concepts and models).

find() is not part of the contract: it is TrustpilotGateway's own method (a business unit looked up by its domain), reached here through get().

No class of a framework is loaded on the way: Tests/BareTest.php checks it in a process of its own, and so does docker compose run --rm omnireview bare (harness).

What the application does around a gateway

  1. Find the place once - a Google place ID, a Tripadvisor location ID, a Trustpilot business unit (find() by domain) - and keep that ID: every platform allows it.
  2. Show the rating and a few reviews with their origin, date and link - the Twig widget does, or the application's own template, keeping to terms(). Keep them no longer than terms()->cacheFor.
  3. Link to the platform to read them all and to write one (writeUrl()).
  4. As the establishment, with its own access: reply (ReplyInterface), invite a customer after a purchase (InviteInterface) - where the platform allows it.

In a framework

  • Symfony: Omnireview\Bridge\Symfony\OmnireviewBundle, in this package, builds the registry from config/packages/omnireview.yaml and registers the Twig functions (Symfony and Twig).
  • Laravel: no bridge; a service provider of the application builds the registry (Laravel).

Proposer une modification docs/installation.md

Symfony and Twig

Omnireview runs without a framework (Installation); in a Symfony application its bundle does the wiring. The bundle lives in this package (Omnireview\Bridge\Symfony), not in omnibridge/symfony. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8), and twig/twig and symfony/cache-contracts for the Twig functions - are not required by omnisense/omnireview: the application has them, and nothing of them is loaded outside Symfony.

Registering the bundle

There is no Flex recipe: add the bundle by hand.

// config/bundles.php
return [
    // ...
    Omnireview\Bridge\Symfony\OmnireviewBundle::class => ['all' => true],
];

Configuration

# config/packages/omnireview.yaml
omnireview:
    gateways:
        google:      { factory: google, options: { api_key: '%env(GOOGLE_PLACES_KEY)%', language: fr } }
        tripadvisor: { factory: tripadvisor, options: { api_key: '%env(TRIPADVISOR_KEY)%', language: fr, referer: 'https://www.example.org/' } }
        trustpilot:  { factory: trustpilot, options: { api_key: '%env(TRUSTPILOT_API_KEY)%' } }
    cache:
        pool: cache.app      # where the Twig functions keep what they show
        ttl: 3600            # at most; never longer than the platform allows

The whole tree, from OmnireviewBundle::configure():

Key Type Default
omnireview.gateways map, keyed by the gateway's name {} the platforms the application uses
omnireview.gateways.<name>.factory string, required, not empty - a factory's name: google, tripadvisor, trustpilot, or an application's own factory's getName()
omnireview.gateways.<name>.options any value (a map) [] handed as they are to the factory's create() (Configuration)
omnireview.cache.pool string or null cache.app the service id of a Symfony cache (Symfony\Contracts\Cache\CacheInterface) the Twig functions keep what they show in; null, or an id that does not exist: nothing is kept
omnireview.cache.ttl integer, at least 0 3600 seconds at most; the platform's Terms::$cacheFor caps it

The options are not validated when the container compiles - the tree accepts any value - but when the gateway is built: a required option missing or empty is an InvalidConfigException naming it, the first time the gateway is asked for.

Services

Service id Class Autowiring
Omnireview\Registry Registry Registry $registry every configured gateway by name. Public.
omnireview.gateway.<name> GatewayInterface GatewayInterface $<name>, RatingInterface $<name>, ReviewsInterface $<name>, ReplyInterface $<name>, InviteInterface $<name>, WriteUrlInterface $<name> one gateway, built by Registry::get('<name>') the first time it is injected
Omnireview\Google\GoogleGatewayFactory, ...\Tripadvisor\TripadvisorGatewayFactory, ...\Trustpilot\TrustpilotGatewayFactory the provider's factory by its class registered when its package is installed (the class exists); autowired, its $http argument the application's http_client service, or null when there is none; tagged omnireview.gateway_factory
Omnireview\Bridge\Twig\OmnireviewExtension the Twig extension - registered when twig/twig is installed; tagged twig.extension; given the registry, the cache pool and the ttl
use Omnireview\RatingInterface;
use Omnireview\ReplyInterface;

final class ReviewsController
{
    public function __construct(
        private readonly RatingInterface $google,          // the "google" gateway
        private readonly ReplyInterface $trustpilot,       // the "trustpilot" gateway
    ) {
    }
}

The argument's name selects the gateway (Symfony's named autowiring aliases, registerAliasForArgument()). A gateway name that is not a valid PHP variable name is still reachable by its service id or by the registry.

An alias is registered under each of the six interfaces for every gateway, whatever it implements: ReplyInterface $tripadvisor compiles, and fails with PHP's TypeError when the service is built (Tripadvisor's gateway implements no ReplyInterface). Ask the registry ($registry->replies('tripadvisor')) for a NotSupportedException that names what it gives.

Nothing is built, nor checked, when the container compiles: a key missing shows on first use. Keys typed in a back office rather than set in the environment: $registry->create('trustpilot', ['api_secret' => $stored]), a gateway built afresh with those options over the configured ones and not kept by the registry.

An application's own platform

A class of the application that implements Omnireview\GatewayFactoryInterface (usually by extending Omnireview\GatewayFactory) is autoconfigured: tagged omnireview.gateway_factory, it joins the registry, and its getName() can be named as a factory (Writing a provider):

omnireview:
    gateways:
        guestbook:
            factory: guestbook     # App\Reviews\GuestbookGatewayFactory::getName()

The bundle registers no command, event, route or controller.

Twig

Function Returns
omnireview_widget(gateway, place, limit = 3, language = null) HTML (safe) @Omnireview/reviews.html.twig rendered: the rating, the reviews, the link to write one - each only when capabilities() gives it
omnireview_rating(gateway, place) Rating or null null when the platform does not answer (UnreachableException)
omnireview_reviews(gateway, place, limit = 5, language = null) list<Review> empty when the platform does not answer
omnireview_write_url(gateway, place) ?string the address to write a review; null when the platform does not answer
omnireview_terms(gateway) Terms the platform's rules; nothing called

gateway is the configured name; place the platform's identifier (a string, made a Place) or a Place (needed for Google's owner side, which reads Place::$owner).

{{ omnireview_widget('google', restaurant.googlePlaceId) }}

{% set rating = omnireview_rating('trustpilot', '46d6a890000064000500e0c3') %}
{% if rating %}{{ rating.value }}/{{ rating.scale }} ({{ rating.count }}){% endif %}

<a href="{{ omnireview_write_url('google', restaurant.googlePlaceId) }}">Leave a review on Google</a>

The widget for a Trustpilot business unit, one review, on the recorded answers:

<section class="omnireview omnireview--trustpilot">
    <header class="omnireview__origin">                        <strong class="omnireview__score">4.4</strong><span class="omnireview__scale">/5</span>
            <span class="omnireview__count">469</span>        <a class="omnireview__platform" href="https://fr.trustpilot.com/review/www.maison-erable.example" rel="noopener" target="_blank">Trustpilot</a>    </header>
            <article class="omnireview__review">
            <p class="omnireview__author">
                                Camille R.                <time datetime="2026-09-30T12:00:00+00:00">30/09/2026</time>                <span class="omnireview__stars">5/5</span>            </p>
            <p class="omnireview__title">Parfait</p>            <blockquote class="omnireview__text">“Livraison rapide, produit conforme.”</blockquote>            <p class="omnireview__reply">Merci Camille !</p>            <a class="omnireview__link" href="https://www.trustpilot.com/reviews/507f191e810c19729de860ea" rel="noopener" target="_blank">Trustpilot</a>        </article>
        <a class="omnireview__write" href="https://fr.trustpilot.com/evaluate/www.maison-erable.example" rel="noopener" target="_blank">Trustpilot</a></section>

(run on 2026-10-26 in the harness's image: OmnireviewExtension on a Twig Environment with @Omnireview pointing at Bridge/Twig/templates, an ArrayAdapter cache, the providers' recorded answers)

What the widget shows

  • A <section class="omnireview omnireview--<factory name>">, with data-nosnippet when terms.noIndex is set (Tripadvisor).
  • The rating: the platform's own picture of the score when it gives one (Rating::$image, Tripadvisor's bubbles, from Tripadvisor's address), the score with one decimal, the scale, the count; then terms.attribution linked to Rating::$url (plain text without a rating or a URL).
  • Each review: the author's photo (32 x 32) and name (linked to authorUrl, rel="noopener nofollow"), its date (<time datetime> in ISO 8601, shown d/m/Y), the platform's picture of its score or rating/scale, its title, its text escaped within quotation marks, the establishment's reply, and terms.attribution linked to the review on the platform.
  • The link to write one, labelled with the gateway's title.

There is no JSON-LD: reviews collected by a third party are not marked up as the site's own AggregateRating (Tests/Bridge/OmnireviewExtensionTest.php checks it).

data-nosnippet keeps the widget out of search snippets only: for Tripadvisor, the page itself must carry <meta name="robots" content="noindex"> - the application's layout decides that, testing omnireview_terms('tripadvisor').noIndex.

Dressing it

The bundle's path is Bridge/Twig (OmnireviewBundle::getPath()), so TwigBundle gives Twig's @Omnireview namespace the application's templates/bundles/OmnireviewBundle/ first, then the bundle's Bridge/Twig/templates. To dress the widget, copy reviews.html.twig to templates/bundles/OmnireviewBundle/reviews.html.twig and change it (clear the cache the first time the directory is created); @!Omnireview/reviews.html.twig still names the bundle's own, to extend it. Keep what omnireview_terms(gateway) requires: attribution shown and linked, each author when authors, a link back when link.

An application can also write its own markup from the other four functions:

{% set terms = omnireview_terms('google') %}
{% set rating = omnireview_rating('google', place_id) %}
<aside class="reviews">
    {% if rating %}<a href="{{ rating.url }}">{{ rating.value|number_format(1) }}/{{ rating.scale }} - {{ rating.count }} reviews on {{ terms.attribution }}</a>{% endif %}
    {% for review in omnireview_reviews('google', place_id, 3) %}
        <blockquote>“{{ review.text }}” - {{ review.author }}, <time datetime="{{ review.publishedAt|date('c') }}">{{ review.publishedAt|date('d/m/Y') }}</time></blockquote>
    {% endfor %}
</aside>

Caching

omnireview_rating() and omnireview_reviews() keep what they fetch in the cache pool for min(ttl, Terms::$cacheFor) seconds, under a key made of the gateway's name, the place, and for reviews the limit and language: not at all for Google (cacheFor 0) nor Tripadvisor (null: its Caching Policy is not public), at most ttl (3600 by default, a day at the very most) for Trustpilot. Without a pool nothing is kept. omnireview_write_url() and omnireview_terms() are not cached; TripadvisorGateway keeps its location details for its own lifetime, so the rating and the write link of a Tripadvisor widget are one call.

A platform that does not answer

omnireview_rating() and omnireview_write_url() give null, omnireview_reviews() an empty list, when the platform raises UnreachableException (no connection, a timeout, a 5xx, a 429): the write link is fetched on Tripadvisor (/details) and Trustpilot (/web-links), so a widget of a platform down shows neither score nor write link, and no error. Any other failure - a key refused (InvalidKeyException), a place unknown (ProviderException), a gateway not configured - reaches the template as an error.

Test the link before writing it:

{% set url = omnireview_write_url('trustpilot', business_unit_id) %}
{% if url %}<a href="{{ url }}">Leave a review on Trustpilot</a>{% endif %}

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnireview provider. None is needed to use the gateways: the registry is plain PHP, and a service provider of the application builds it once, from a config file. The Twig functions and the widget are Twig's; a Blade view reads the models directly.

// config/omnireview.php
return [
    'gateways' => [
        'google' => ['factory' => 'google', 'options' => [
            'api_key' => env('GOOGLE_PLACES_KEY'),
            'language' => 'fr',
        ]],
        'trustpilot' => ['factory' => 'trustpilot', 'options' => [
            'api_key' => env('TRUSTPILOT_API_KEY'),
            'api_secret' => env('TRUSTPILOT_API_SECRET'),
            'business_user_id' => env('TRUSTPILOT_BUSINESS_USER_ID'),
        ]],
    ],
];
// app/Providers/OmnireviewServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnireview\Google\GoogleGatewayFactory;
use Omnireview\Registry;
use Omnireview\Trustpilot\TrustpilotGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

final class OmnireviewServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create();

            return new Registry([
                new GoogleGatewayFactory($http),
                new TrustpilotGatewayFactory($http),
            ], config('omnireview.gateways'));
        });
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry:

use Omnireview\Model\Place;
use Omnireview\Registry;

final class ReviewsController
{
    public function show(Registry $registry)
    {
        $place = new Place(config('services.google.place_id'));

        return view('reviews', [
            'rating' => $registry->rating('google')->rating($place),
            'reviews' => $registry->reviews('google')->reviews($place, 3),
            'terms' => $registry->get('google')->terms(),
        ]);
    }
}

The HTTP client is symfony/http-client's (every provider requires it; Laravel's own HTTP client is Guzzle's, which the factories do not take). List in the provider every factory the application uses: a hand-built registry knows only the factories it is given. Keep what is shown no longer than Terms::$cacheFor (Cache::remember() with that many seconds, and nothing when it is 0 or null), and show each rating and review with its origin, author, date and link, as the Twig widget does (Symfony and Twig).

This page was written from the registry's API; it was not run in a Laravel application.

Proposer une modification docs/laravel.md

Concepts and models

The contract

A gateway is one platform, configured. It implements Omnireview\GatewayInterface and the capability interfaces it gives; each capability interface extends GatewayInterface.

Interface Method
GatewayInterface getName(): string the factory's name: google, tripadvisor, trustpilot
getTitle(): string the platform's name for people: Google
capabilities(): Capabilities what it gives, as configured
terms(): Terms how what it gives may be shown and kept
RatingInterface rating(Place $place): Rating the establishment's score, its count, its page; @throws UnreachableException when the platform did not answer, ProviderException when it does not know the place
ReviewsInterface reviews(Place $place, int $limit = 5, ?string $language = null): list<Review> reviews in the platform's order, at most $limit (the platform may give fewer: Terms::$maxReviews); $language where the platform filters or translates
ReplyInterface reply(Place $place, Review $review, string $text): Reply answering a review as the establishment: the owner's access needed
InviteInterface invite(Place $place, Invitation $invitation): ?string a customer invited by the platform's own e-mail; the platform's reference for the invitation, when it gives one
WriteUrlInterface writeUrl(Place $place): string the address where a customer writes a review

A gateway may implement an interface and not give it as configured - Google's and Trustpilot's gateways implement ReplyInterface whether or not the owner's access is set: capabilities() then says reply: false and reply() throws NotSupportedException. A gateway keeps nothing between requests beyond an OAuth token (and Tripadvisor's location details, for its own lifetime): what to show, and for how long, is the application's, within the Terms.

A factory (GatewayFactoryInterface) builds a gateway from options:

Method
getName(): string the name gateways are configured with
create(array $options = []): GatewayInterface a gateway; InvalidConfigException when an option is missing

Omnireview\GatewayFactory is the abstract base every provider extends (Writing a provider); Omnireview\Registry holds the gateways by name (Configuration).

Models

All are final readonly classes in Omnireview\Model, built with named arguments.

Place

The establishment on one platform, as the application keeps it (every platform allows keeping its identifier).

Property Type Default
id string required the platform's identifier: a Google place ID (ChIJ...), a Tripadvisor location ID (1234567), a Trustpilot business unit ID
name ?string null the establishment's name; Google uses it in the Maps search link of its owner's side
owner ?string null a second identifier the owner's side needs: Google Business Profile's accounts/<a>/locations/<l>

Rating

Property Type Default
platform string required the factory's name
value float required the score, on the platform's scale
count int required the reviews (or ratings) it rests on
scale int 5 the scale's top (5 on every platform today)
url ?string null the establishment's page on the platform: link it
image ?string null the platform's own picture of the score (Tripadvisor's bubbles), shown from its address
fetchedAt DateTimeImmutable now when it was read

outOfFive(): float - the score on 5, rounded to a tenth (8.6 on 10 is 4.3).

Review

Property Type Default
platform string required the factory's name
id string required the platform's identifier: what a reply needs
rating float required on the platform's scale
text ?string null as its author wrote it, or the platform's translation
title ?string null Tripadvisor's and Trustpilot's
language ?string null the text's language code
author ?string null as the platform names them
authorUrl ?string null their profile on the platform
authorPhoto ?string null their picture's address
publishedAt ?DateTimeImmutable null
url ?string null the review on the platform, or the establishment's page when the platform gives no review an address of its own: link it
reply ?Reply null the establishment's answer
scale int 5
translated bool false the text is the platform's translation
image ?string null the platform's picture of this review's score

A review's text is the platform's: show it escaped, within quotation marks, unaltered - shortened only with a link to the whole.

Reply

Property Type Default
text string required the establishment's answer
at ?DateTimeImmutable null when it was published or last changed

Invitation

A customer to invite.

Property Type Default
email string required
name string required
reference ?string null what they bought: an order number
locale string fr-FR the invitation's language
sendAt ?DateTimeImmutable null when the platform sends it; null: not sent to the platform, which then sends at its own default time
redirectUrl ?string null where the customer lands after writing their review

Terms

A platform's rules for what it gives, declared by its package from the platform's documentation.

Property Type Default
cacheFor ?int 0 seconds what it gives may be kept before asking again: 0 never, null "as the platform's caching policy allows" (not published, so treated as never)
maxReviews ?int null the reviews one call gives at most
attribution string '' what must be shown with it: the platform's name or logo
authors bool true each review shown with its author as the platform names them
link bool true a link back to the platform shown with it
noIndex bool false pages that show it are kept out of search engines
rules list of strings [] the other rules, in words
source ?string null where they are written

allowsCaching(int $seconds): bool - whether it may be kept $seconds: false when cacheFor is null, else $seconds <= cacheFor.

What each provider declares:

cacheFor maxReviews attribution noIndex source
Google 0 5 Google Maps false developers.google.com/maps/documentation/places/web-service/policies
Tripadvisor null 5 Tripadvisor true tripadvisor-content-api.readme.io/reference/display-requirements
Trustpilot 86400 100 Trustpilot false developers.trustpilot.com/ds-caching-best-practices/

Every provider declares authors: true and link: true; their rules are listed in their manuals.

Capabilities

What a gateway gives, as configured.

Property Type Default
rating bool false
reviews bool false
reply bool false
invite bool false
writeUrl bool false
  • gives(): list<string> - the names of those that are true, in this order: rating, reviews, reply, invite, write-url.
  • toArray(): array - ['gives' => gives()] and the five booleans.

Over HTTP: Answer

Omnireview\Http\Answer is the one place the providers call their platform through:

Answer::send(HttpClientInterface $http, string $provider, string $method, string $url, array $options = []): Answer

It sends the request (the HTTP client's options: query, headers, json, body...; a timeout of 10 seconds unless one is given) and reads the whole answer. No connection, a timeout, any transport error, an HTTP status of 500 or more, or 429 is an UnreachableException (its providerCode the status for an HTTP one); anything else comes back as an Answer:

Member
status int: the HTTP status
headers array<string, list<string>>, names in lower case
body string: the whole body
header(string $name): ?string the first value of a header, by any case
json(): array the body as a JSON object; ProviderException (HTTP <status>, not a JSON object: ...) otherwise

What a 4xx means is the provider's to say (a refused key, an unknown place).

Proposer une modification docs/models.md

Usage

One section per capability. Every example takes a Registry built as in Installation (or injected, Symfony); $place is a Omnireview\Model\Place.

What each platform gives

Google Tripadvisor Trustpilot
Rating Places API (New); the owner's Business Profile when the Place has an owner and the owner's access is set Content API, /details Data Solutions API, /business-units/{id}
Reviews 5 (Places), up to 50 (Business Profile) 5 up to 100
Reply Business Profile: the owner's refresh token and Place::$owner - (not in the Content API) the business user's token
Invite - (Google has none: share writeUrl()) - Invitations API
Write URL search.google.com/local/writereview?placeid=, nothing called write_review of /details evaluateUrl of /web-links
Kept never; the place ID only per its Caching Policy (not public): not kept 24 hours at most
Pages not indexed no yes no

Each provider's manual has its options, its calls, its terms in full and what was verified.

Asking for a capability

The registry's typed getters give the gateway as the interface asked for:

Getter Returns
rating($name) RatingInterface
reviews($name) ReviewsInterface
replies($name) ReplyInterface
invitations($name) InviteInterface
writeUrl($name) WriteUrlInterface

A gateway that does not implement the interface, or does not give it as configured (its capabilities() say no: Trustpilot without a business user does not reply), is a NotSupportedException naming what it gives: The "google" gateway (Google) does not give inviting a customer to review; it gives: rating, reviews, write-url., The "trustpilot" gateway (Trustpilot) does not give replying to a review; it gives: rating, reviews, write-url.

So test capabilities() before offering an action, rather than catch the getter's exception:

$gateway = $registry->get('trustpilot');
if ($gateway->capabilities()->reply) {
    // show the "Reply" button
}

Rating

$rating = $registry->rating('tripadvisor')->rating(new Place('1234567'));

printf('%.1f/%d from %d reviews', $rating->value, $rating->scale, $rating->count);   // 4.5/5 from 312 reviews
$rating->url;          // the establishment's page on Tripadvisor: link it
$rating->image;        // Tripadvisor's bubbles: show them from that address
$rating->outOfFive();  // the score on 5, for a scale other than 5
  • Google: rating and userRatingCount of the place (X-Goog-FieldMask: id,displayName,rating,userRatingCount,googleMapsUri), url its googleMapsUri. For a Place with an owner and a gateway with the owner's access, averageRating and totalReviewCount of the Business Profile instead, url a Google Maps search link of the place.
  • Tripadvisor: rating, num_reviews, web_url, rating_image_url of /details in the gateway's language.
  • Trustpilot: score.trustScore and numberOfReviews.total, url the profileUrl of the business unit's /web-links (a second call).

A platform that does not know the place answers with an error: ProviderException (Google's NOT_FOUND in providerCode).

Reviews

foreach ($registry->reviews('google')->reviews($place, limit: 3, language: 'en') as $review) {
    echo $review->author, ' ', $review->publishedAt?->format('Y-m-d'), ' ', $review->rating, '/', $review->scale, "\n";
    echo '"', $review->text, '"', $review->translated ? ' (translated by Google)' : '', "\n";
    echo $review->url, "\n";
    if ($review->reply) {
        echo '  the establishment: ', $review->reply->text, "\n";
    }
}

The reviews come in the platform's order, never more than $limit (each gateway slices what it is given) and never more than the platform gives:

Asked Order $language
Google, Places the place's reviews field: Google gives 5 at most Google's relevance languageCode; Google translates the others and says so (translated)
Google, Business Profile pageSize = $limit between 1 and 50 updateTime desc not sent
Tripadvisor limit = $limit between 1 and 5 Tripadvisor's: the most recent language (the gateway's when null); translated from is_machine_translated
Trustpilot perPage = $limit between 1 and 100 createdat.desc language, a filter; not sent when null

A review's url is its page on the platform where the platform gives one (Google's Places, Tripadvisor); Trustpilot's is built as https://www.trustpilot.com/reviews/<id> (its API's own links point to the API); Google's Business Profile gives none, and the place's Google Maps link stands in.

Replying

As the establishment, with its own access (Google: client_id, client_secret, refresh_token and a Place with owner; Trustpilot: api_secret and business_user_id):

$place = new Place('ChIJLU7jZClu5kcR4PcOOO6p3I0', 'Maison Érable', 'accounts/101/locations/202');
$google = $registry->replies('google');

foreach ($registry->reviews('google')->reviews($place, 10) as $review) {
    if (null === $review->reply && $review->rating <= 3) {
        $reply = $google->reply($place, $review, 'Merci pour votre retour, nous en tenons compte.');
        echo $reply->text, ' ', $reply->at?->format(DATE_ATOM), "\n";
    }
}

The review must come from the owner's side: its id is the identifier the reply goes to (a Google Places review's id is not a Business Profile review ID). Google answers with the reply as published (PUT .../reviews/{id}/reply); Trustpilot's Reply is the text sent, dated now. Without the access, reply() throws NotSupportedException before any call.

Inviting

Trustpilot only, with a business user:

use Omnireview\Model\Invitation;

$reference = $registry->invitations('trustpilot')->invite(new Place($businessUnitId), new Invitation(
    email: 'camille@example.org',
    name: 'Camille R.',
    reference: 'ORDER-42',
    locale: 'fr-FR',
    sendAt: new DateTimeImmutable('+3 days'),
    redirectUrl: 'https://www.maison-erable.example/merci',
));

The invitation's id comes back, or null when Trustpilot gives none. Invite every customer alike: Google and Trustpilot both forbid soliciting only those likely to be pleased. Google has no invitation API: give a customer writeUrl() instead.

$registry->writeUrl('google')->writeUrl($place);       // https://search.google.com/local/writereview?placeid=ChIJ...

Google's is built without a call; Tripadvisor's is the write_review of the location's details (the same call as its rating); Trustpilot's the evaluateUrl of /web-links in the gateway's locale. A platform that gives none is a ProviderException (The location gives no write_review link.).

The terms

$terms = $registry->get('tripadvisor')->terms();

$terms->attribution;              // "Tripadvisor": show it, linked to $rating->url
$terms->noIndex;                  // true: the page that shows it carries <meta name="robots" content="noindex">
$terms->allowsCaching(3600);      // false: not kept
foreach ($terms->rules as $rule) {
    echo $rule, "\n";             // the rest, in words
}

The Twig extension applies cacheFor, attribution, authors, link and noIndex (the widget's data-nosnippet); an application that renders by itself applies them too (Symfony and Twig).

Proposer une modification docs/gateways.md

Configuration reference

Every option below is read from the factories' code at this commit (populate() and what build() reads). Options reach a factory through create($options), a Registry's options, or the bundle's omnireview.gateways.<name>.options (Symfony).

The registry

new Registry(iterable $factories, array $config);
// $config: ['google' => ['factory' => 'google', 'options' => ['api_key' => '...']], ...]
Method
get(string $name): GatewayInterface the gateway, built on the first call and kept
has(string $name): bool whether it is configured
rating(), reviews(), replies(), invitations(), writeUrl() the gateway as that interface, or NotSupportedException (Usage)
options(string $name): array the options it is configured with
create(string $name, array $overrides = []): GatewayInterface a gateway built afresh, its options with $overrides over them (array_replace), not kept
names(): list<string> the configured gateways' names
all(): array<string, GatewayInterface> every configured gateway, built
factories(): list<string> the factories installed: what factory may name

A later factory of the same name replaces an earlier one. A name not configured is an InvalidConfigException (No "x" gateway; configured: google, trustpilot.), so is a factory not installed (No "yelp" factory for the "x" gateway; installed: google, trustpilot.).

How options are read

Omnireview\Config (an ArrayObject) holds the options given, with the factory's defaults under them (defaults() sets only the keys not given). Before build(), validateNotEmpty() refuses each key of omnireview.required_options that is missing, '' or []. The readers:

Reader
get($key, $default = null) the value as given
string($key): ?string the value as a string; null when it is null or '' - so an empty environment variable is "not set"
bool($key): bool a boolean, or "1", "true", "yes", "on" (any case) as true
list($key): list<string> a list, or a comma-separated string split and trimmed

The three keys every factory sets for itself:

Key
omnireview.factory_name the factory's name (getName() reads it)
omnireview.factory_title its title
omnireview.required_options the options that must not be empty

google

new GoogleGatewayFactory(?Symfony\Contracts\HttpClient\HttpClientInterface $http = null) (HttpClient::create() when null).

Option Type Default Required
api_key string null one side or the other the Places API (New) key: anyone's place
language string null no the reviews' language (languageCode of the Places API), unless a call gives its own
client_id string null for the owner's side an OAuth 2.0 client
client_secret string null for the owner's side
refresh_token string null for the owner's side granted once by the owner, scope business.manage

api_key, or all three of the owner's, or both; neither is an InvalidConfigException (The "google" gateway needs an api_key (Places), or the owner's client_id, client_secret and refresh_token (Business Profile).). reply is given only with the owner's three.

tripadvisor

new TripadvisorGatewayFactory(?HttpClientInterface $http = null).

Option Type Default Required
api_key string - yes the Content API's partner key
language string en no the content's language (fr, de, ja...)
referer string null no the Referer header sent: for a key restricted to domains

trustpilot

new TrustpilotGatewayFactory(?HttpClientInterface $http = null).

Option Type Default Required
api_key string - yes the application's key: the TrustScore, the reviews, the links
locale string fr-FR no the locale of the profile's links (/web-links); an invitation is sent in its own Invitation::$locale, never this option
api_secret string null for reply and invite the application's secret: the business user's token
business_user_id string null for reply and invite the business user who replies and invites
template_id string null no the invitation e-mail's template
reply_to string null no the invitation's reply-to address
sender_name string null no the invitation's sender name
sender_email string null no the invitation's sender address

reply and invite are given only with api_secret and business_user_id.

The bundle

omnireview.gateways and omnireview.cache (Symfony and Twig). The Twig extension's constructor, for a hand-built Twig environment:

new OmnireviewExtension(Registry $registry, ?Symfony\Contracts\Cache\CacheInterface $cache = null, int $ttl = 3600);

Proposer une modification docs/configuration.md

Errors

Every exception of the family implements Omnireview\Exception\OmnireviewException (an interface extending \Throwable):

Exception Extends Thrown when
InvalidConfigException \LogicException a gateway not configured, a factory not installed, a required option missing or empty, Google with neither its key nor the owner's access
NotSupportedException \LogicException the gateway does not give that: the registry's getter for an interface the gateway does not implement, or a reply()/invite() without the owner's or business user's access
ProviderException \RuntimeException the platform answered with an error: a place it does not know, a request it refused, a body that is not JSON, a write link it does not give
InvalidKeyException ProviderException the platform refused the key, the token or the account (HTTP 401 or 403; a refresh token or client credentials refused; Google's Places without an api_key)
UnreachableException ProviderException the platform did not answer: no connection, a timeout, an HTTP status of 500 or more, or 429

ProviderException and its two children keep what the platform said:

Property
provider the factory's name: google, tripadvisor, trustpilot
providerCode the platform's own code, when it gave one: Google's status (NOT_FOUND, PERMISSION_DENIED), Tripadvisor's error.code or the HTTP status, Trustpilot's HTTP status, the HTTP status of an UnreachableException
getMessage() [<provider>] <message>, the message naming the HTTP status and the platform's own words
getPrevious() a copy of the HTTP client's exception, its secrets masked, for an UnreachableException without an answer (No secret in a failure)

The messages, by thrower:

Where Message
Registry::get() No "<name>" gateway; configured: <names>. / No "<factory>" factory for the "<name>" gateway; installed: <factories>.
Config::validateNotEmpty() The "<factory>" gateway needs: <options>.
NotSupportedException::question() The "<name>" gateway (<title>) does not give <what>; it gives: <capabilities>.
NotSupportedException::operation() The "<name>" gateway does not <operation>: <why>.
Answer::send() [<provider>] No answer: <transport error, its secrets masked> / [<provider>] HTTP <status>: <first 200 characters of the body>
Answer::json() [<provider>] HTTP <status>, not a JSON object: <body>

What to catch:

use Omnireview\Exception\InvalidKeyException;
use Omnireview\Exception\ProviderException;
use Omnireview\Exception\UnreachableException;

try {
    $rating = $registry->rating('trustpilot')->rating($place);
} catch (UnreachableException) {
    $rating = null;                                     // show nothing: the platform is down or asked to slow down
} catch (InvalidKeyException $e) {
    $logger->critical($e->getMessage());                // the configuration is wrong
    $rating = null;
} catch (ProviderException $e) {
    $logger->error($e->getMessage(), ['code' => $e->providerCode]);   // an unknown place
    $rating = null;
}

InvalidConfigException and NotSupportedException are programming errors (a LogicException): test capabilities() and Registry::has() rather than catch them.

What is not an exception

  • A platform that gives fewer reviews than asked, or none: a shorter list.
  • A field the platform leaves out: null (or 0 for a score or a count) in the model.
  • A date the platform writes in a form PHP cannot read: publishedAt null.
  • In Twig, UnreachableException from omnireview_rating() and omnireview_reviews(): null and [] (Symfony and Twig).

Outside the family

The HTTP client's own exceptions never reach the application: Answer::send() makes them UnreachableExceptions. PHP's TypeError comes from injecting, in Symfony, a gateway under an interface it does not implement (Symfony).

No secret in a failure

Answer::send() keeps the request's secrets out of what it throws. The HTTP client's message quotes the URL called: its key parameters, a password in its user part and every secret of the request's options are masked (api_key=***, Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message, its own previous copied the same way), and no trace of the chain keeps its arguments, where the query, headers and body stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key.

Proposer une modification docs/errors.md

Testing

Testing an application that shows or answers reviews through omnireview, and what the family's own tests cover. The core ships no fake gateway and no conformance suite: Tests/StubFactory.php is the core's own test double, autoloaded only in its development (autoload-dev). An application tests its code either against a real provider on a MockHttpClient, or with a gateway of its own.

A provider on a MockHttpClient

Every factory takes its HTTP client in its constructor. Given Symfony's MockHttpClient, the provider runs whole - its requests, its mapping, its errors - and nothing leaves the machine:

<?php

use Omnireview\Exception\UnreachableException;
use Omnireview\Google\GoogleGatewayFactory;
use Omnireview\Model\Place;
use Omnireview\Registry;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\HttpClient\Response\MockResponse;

/** The application's code: a one-line badge, or nothing when the platform is down. */
final class ReviewBadge
{
    public function __construct(private readonly Registry $registry)
    {
    }

    public function line(string $gateway, string $placeId): ?string
    {
        try {
            $rating = $this->registry->rating($gateway)->rating(new Place($placeId));
        } catch (UnreachableException) {
            return null;
        }

        return \sprintf('%.1f/%d (%d) on %s', $rating->value, $rating->scale, $rating->count, $this->registry->get($gateway)->terms()->attribution);
    }
}

final class ReviewBadgeTest extends TestCase
{
    private function registry(MockHttpClient $http): Registry
    {
        return new Registry([new GoogleGatewayFactory($http)], ['google' => ['factory' => 'google', 'options' => ['api_key' => 'test-key']]]);
    }

    public function testTheBadgeIsTheRatingCreditedToThePlatform(): void
    {
        $answer = new JsonMockResponse(['id' => 'ChIJ1', 'rating' => 4.6, 'userRatingCount' => 128, 'googleMapsUri' => 'https://maps.google.com/?cid=1']);
        $http = new MockHttpClient($answer);

        self::assertSame('4.6/5 (128) on Google Maps', (new ReviewBadge($this->registry($http)))->line('google', 'ChIJ1'));
        self::assertSame('https://places.googleapis.com/v1/places/ChIJ1', $answer->getRequestUrl());
        self::assertContains('X-Goog-Api-Key: test-key', $answer->getRequestOptions()['headers']);
    }

    public function testNothingWhenThePlatformIsDown(): void
    {
        $http = new MockHttpClient(new MockResponse('', ['http_code' => 503]));

        self::assertNull((new ReviewBadge($this->registry($http)))->line('google', 'ChIJ1'));
    }
}
$ vendor/bin/phpunit --testdox tests/
Review Badge
 ✔ The badge is the rating credited to the platform
 ✔ Nothing when the platform is down

OK (2 tests, 4 assertions)

(run on 2026-10-26 in the harness's image, PHPUnit 11.5.57, no network)

The answers each provider expects are in its Tests/Fixtures (Google's place.json, Tripadvisor's details.json, Trustpilot's business-unit.json...), written from each platform's reference; the harness's docker/harness/config/recorded.php is a MockHttpClient callback that serves all of them by URL, for a test that needs every platform at once:

$http = new MockHttpClient(require 'vendor/omnisense/omnireview/docker/harness/config/recorded.php');

A 503, a 429 or a transport error tests the "platform down" path (UnreachableException); a 401 or 403 the refused key (InvalidKeyException); a 404 with the platform's error body the unknown place (ProviderException).

A gateway of the application's own

For code that only needs models back, a class implementing the capability interfaces it needs, in the test's namespace, given to the registry through a factory:

use Omnireview\Config;
use Omnireview\GatewayFactory;
use Omnireview\GatewayInterface;
use Omnireview\Model\{Capabilities, Place, Rating, Terms};
use Omnireview\RatingInterface;

final class FixedRatingFactory extends GatewayFactory
{
    protected function populate(Config $c): void
    {
        $c->defaults(['omnireview.factory_name' => 'fixed', 'omnireview.factory_title' => 'Fixed', 'value' => 4.5]);
    }

    protected function build(Config $c): GatewayInterface
    {
        return new class((float) $c['value']) implements RatingInterface {
            public function __construct(private readonly float $value) {}
            public function getName(): string { return 'fixed'; }
            public function getTitle(): string { return 'Fixed'; }
            public function capabilities(): Capabilities { return new Capabilities(rating: true); }
            public function terms(): Terms { return new Terms(cacheFor: 3600, attribution: 'Fixed'); }
            public function rating(Place $place): Rating { return new Rating('fixed', $this->value, 10); }
        };
    }
}

$registry = new Registry([new FixedRatingFactory()], ['google' => ['factory' => 'fixed']]);

The core's own Tests/StubFactory.php is such a gateway (rating, reviews and write link from an option host, a call counter, the place down raising UnreachableException); copy it rather than depend on it.

Twig and Symfony

  • The Twig extension takes any CacheInterface: give it Symfony's ArrayAdapter and a call counter to test what is kept, as Tests/Bridge/OmnireviewExtensionTest.php does; with null, nothing is kept.
  • In a Symfony functional test, the registry is the public service Omnireview\Registry; replace the HTTP client the factories receive by configuring framework.http_client.mock_response_factory in the test environment, or configure the gateway under the test environment with a factory of the application's own (autoconfigured, Symfony). Neither was run in an application for this page.

The family's own tests

The workspace (~/Sites/omnireview, every package from its checkout) runs 21 tests, 136 assertions (Verified, and not):

Test What it checks
RegistryTest::testEachCapabilityIsAskedOfAGatewayThatGivesIt the typed getter gives the gateway; one it does not implement is a NotSupportedException with its message; a required option missing is an InvalidConfigException
RegistryTest::testAGetterRefusesWhatTheGatewayDoesNotGiveAsConfigured a gateway that implements ReplyInterface but whose capabilities() say no reply: replies() is a NotSupportedException naming what it gives
RegistryTest::testRatingsTermsAndCapabilities outOfFive() on a scale of 10, allowsCaching() for 86400, 0 and null, gives()
OmnireviewBundleTest (3) gateways built from configuration and injected by argument name; the providers installed registered with the application's http_client; the widget under the bundle's path's templates/ and no twig.paths prepended, so templates/bundles/OmnireviewBundle/ overrides it
OmnireviewExtensionTest (3) the widget's origin, date, links, escaped text and no JSON-LD; kept for an hour, never for 0 or null; a platform down shows nothing: no rating, no write link, a widget without one
BareTest (3) bare --json in a process of its own: no framework class loaded, each platform's rating, reviews and links; loading the bundle does name the framework
GoogleGatewayTest (3), TripadvisorGatewayTest (3), TrustpilotGatewayTest (3) each provider on MockHttpClient and its fixtures (their manuals)

Proposer une modification docs/testing.md

The Docker harness

docker/ runs this package with every omnireview/* gateway installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNIREVIEW_PLUGINS=../.. is set in docker/.env.

cd docker && cp .env.dist .env       # a platform's key and a place, when you have them
docker compose run --rm omnireview bare
Command
gateways the gateways installed and configured, what each gives, its terms - nothing called
bare plain PHP: the registry built by hand, each platform asked a rating, reviews, its write link; what PHP loaded
bare --live the same, each platform whose key and place are set in .env asked for real
bare --json the same, whole: every class and file loaded
test every package's tests

Bare: no bundle, no container, no Twig

docker/harness/bin/bare requires the autoloader and nothing else. It builds the Registry from the platform packages installed and asks each a place's rating, three reviews and the link to write one - the platforms answering from their packages' recorded answers - then lists what PHP loaded, and exits 1 if a class of a framework is among it:

$ docker compose run --rm omnireview bare
Omnireview in bare PHP: the registry built by hand, no bundle, no container, no Twig; the platforms' recorded answers.

  google      Google: gives rating reviews write-url; kept never, 5 reviews at most, credited to "Google Maps"
  tripadvisor Tripadvisor: gives rating reviews write-url; kept as its policy says, 5 reviews at most, credited to "Tripadvisor", pages not indexed
  trustpilot  Trustpilot: gives rating reviews write-url; kept 86400 s, 100 reviews at most, credited to "Trustpilot"

  google      4.6/5 from 128 reviews, https://maps.google.com/?cid=10223305813012340704
                5 by Camille R. on 2026-09-23, https://www.google.com/maps/reviews/data=1
                4 by Jordan K. on 2026-09-01, https://www.google.com/maps/reviews/data=2
                to write one: https://search.google.com/local/writereview?placeid=ChIJLU7jZClu5kcR4PcOOO6p3I0
  tripadvisor 4.5/5 from 312 reviews, https://www.tripadvisor.fr/Restaurant_Review-g187147-d1234567-Reviews-Maison_Erable-Paris.html
                5 by Camille R on 2026-09-20, https://www.tripadvisor.fr/ShowUserReviews-g187147-d1234567-r900001-Maison_Erable-Paris.html
                to write one: https://www.tripadvisor.fr/UserReview-g187147-d1234567-Maison_Erable-Paris.html
  trustpilot  4.4/5 from 469 reviews, https://fr.trustpilot.com/review/www.maison-erable.example
                5 by Camille R. on 2026-09-30, https://www.trustpilot.com/reviews/507f191e810c19729de860ea
                to write one: https://fr.trustpilot.com/evaluate/www.maison-erable.example

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle or bridge, Doctrine, Twig): none

Tests/BareTest.php runs bare --json in a process of its own and checks the list - and, so that the check is not blind, that loading the bundle does name the framework.

The image is php:8.4-cli-alpine with Composer and the zip extension (omnisense/omnireview-harness); the harness's packages live in the harness volume of the omnireview-harness project.

How it is composed

docker/entrypoint.sh runs docker/harness/setup.php, which writes /harness/composer.json and /harness/phpunit.xml: this core from its checkout (a path repository, /omnireview/core), each gateway of docker/harness/plugins.php (google, tripadvisor, trustpilot: its tests' namespace and its factory class) from its checkout under /omnireview/plugins when there is one whose composer.json names it, else from https://github.com/glitchr-omnisense/omnireview-<slug>.git (branch 1.x). Composer installs when vendor/ is missing and updates when composer.json is newer than its lock. Then the first argument picks the command:

Argument Runs
test vendor/bin/phpunit with the rest of the arguments: one test suite per package
bare docker/harness/bin/bare
composer, sh, php that program, as it is
anything else (default gateways) the console, docker/harness/bin/omnireview

Settings

docker/.env (copied from .env.dist, git-ignored):

Variable
OMNIREVIEW_PLUGINS where the gateways' checkouts are (../.. in the workspace); unset, they come from GitHub
GOOGLE_PLACES_KEY, GOOGLE_PLACE_ID the Places API key and a place: google is configured with both
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, GOOGLE_LOCATION the owner's side: the OAuth client, the refresh token, accounts/<a>/locations/<l>
TRIPADVISOR_KEY, TRIPADVISOR_LOCATION, TRIPADVISOR_REFERER the Content API key, a location ID, the Referer
TRUSTPILOT_API_KEY, TRUSTPILOT_BUSINESS_UNIT the key and a business unit ID
TRUSTPILOT_API_SECRET, TRUSTPILOT_BUSINESS_USER_ID replying and inviting

docker/harness/config/gateways.php reads them: a gateway is configured from them only when every variable of its needs is set, else with example options (api_key: example) and an example place. gateways prints which; bare uses the example options with the recorded answers unless --live is given and the variables are set. OMNIREVIEW_AUTOLOAD makes bare use another autoloader (a workspace's vendor/autoload.php).

The console

docker compose run --rm omnireview gateways builds the registry from the installed factories and prints, nothing called: each gateway, whether it is installed, whether its settings come from .env or the example (naming the variables missing), what it gives, how long it may be kept, the reviews per call, and what it is credited to.

Running against the workspace

Without the harness's volume, the image runs the workspace's own vendor/ (every package symlinked from its checkout), read-only and offline:

docker run --rm --network none --entrypoint php -v ~/Sites/omnireview:/w:ro -w /w \
    omnisense/omnireview-harness vendor/bin/phpunit --cache-directory /tmp/pu

This is how the examples of this manual were run on 2026-10-26 (19 tests, 129 assertions, green).

Proposer une modification docs/harness.md

Writing a provider

A provider is a package of its own, omnireview/<platform>, requiring omnisense/omnireview and symfony/http-client, with:

  • a factory extending Omnireview\GatewayFactory: populate(Config) sets its name, title, required options and the defaults of the others; build(Config) returns the gateway. It takes its HTTP client in its constructor (?HttpClientInterface $http = null, named $http: the Symfony bundle gives the application's http_client to an argument of that name);
  • a gateway implementing the capability interfaces it gives, returning the models, its HTTP calls made through Omnireview\Http\Answer::send() (a transport error, a 5xx or a 429 become UnreachableException), a 401 or 403 an InvalidKeyException, another error a ProviderException;
  • capabilities() true to the configuration: a capability that needs an access the options do not hold is false, and its method throws NotSupportedException::operation();
  • terms() read in the platform's documentation, with source: how long it may be kept (0, null when the platform's policy is not public), the reviews per call, the attribution, noIndex, the other rules in words;
  • tests on MockHttpClient with the platform's answers in Tests/Fixtures.

A whole provider

A platform whose JSON API gives a place's average, its reviews, and a page to write one:

<?php

namespace Acme\Guestbook;

use Omnireview\Config;
use Omnireview\GatewayFactory;
use Omnireview\GatewayInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class GuestbookGatewayFactory extends GatewayFactory
{
    public function __construct(private readonly ?HttpClientInterface $http = null)
    {
    }

    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnireview.factory_name' => 'guestbook',
            'omnireview.factory_title' => 'Guestbook',
            'omnireview.required_options' => ['token'],
            'base_url' => 'https://api.guestbook.example/v2/',
        ]);
    }

    protected function build(Config $c): GatewayInterface
    {
        return new GuestbookGateway($this->http ?? HttpClient::create(), (string) $c['token'], (string) $c->string('base_url'));
    }
}
<?php

namespace Acme\Guestbook;

use Omnireview\Exception\InvalidKeyException;
use Omnireview\Exception\ProviderException;
use Omnireview\Http\Answer;
use Omnireview\Model\Capabilities;
use Omnireview\Model\Place;
use Omnireview\Model\Rating;
use Omnireview\Model\Review;
use Omnireview\Model\Terms;
use Omnireview\RatingInterface;
use Omnireview\ReviewsInterface;
use Omnireview\WriteUrlInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class GuestbookGateway implements RatingInterface, ReviewsInterface, WriteUrlInterface
{
    public function __construct(
        private readonly HttpClientInterface $http,
        #[\SensitiveParameter] private readonly string $token,
        private readonly string $baseUrl,
    ) {
    }

    public function getName(): string
    {
        return 'guestbook';
    }

    public function getTitle(): string
    {
        return 'Guestbook';
    }

    public function capabilities(): Capabilities
    {
        return new Capabilities(rating: true, reviews: true, writeUrl: true);
    }

    public function terms(): Terms
    {
        return new Terms(cacheFor: 3600, maxReviews: 20, attribution: 'Guestbook', source: 'https://guestbook.example/terms');
    }

    public function rating(Place $place): Rating
    {
        $data = $this->call('places/'.rawurlencode($place->id));

        return new Rating('guestbook', (float) ($data['average'] ?? 0), (int) ($data['count'] ?? 0), url: $data['url'] ?? null);
    }

    public function reviews(Place $place, int $limit = 5, ?string $language = null): array
    {
        $data = $this->call('places/'.rawurlencode($place->id).'/reviews', array_filter(['per_page' => max(1, min(20, $limit)), 'lang' => $language]));

        return array_slice(array_map(static fn (array $r) => new Review(
            'guestbook',
            (string) $r['id'],
            (float) $r['stars'],
            $r['body'] ?? null,
            author: $r['author'] ?? null,
            publishedAt: isset($r['date']) ? new \DateTimeImmutable($r['date']) : null,
            url: $r['url'] ?? null,
        ), $data['items'] ?? []), 0, $limit);
    }

    public function writeUrl(Place $place): string
    {
        return 'https://guestbook.example/write/'.rawurlencode($place->id);
    }

    /** @return array<string, mixed> */
    private function call(string $path, array $query = []): array
    {
        $answer = Answer::send($this->http, 'guestbook', 'GET', $this->baseUrl.$path, ['query' => $query, 'headers' => ['Authorization' => 'Bearer '.$this->token]]);
        if (\in_array($answer->status, [401, 403], true)) {
            throw new InvalidKeyException('guestbook', \sprintf('The token was refused (HTTP %d).', $answer->status), (string) $answer->status);
        }
        if ($answer->status >= 400) {
            throw new ProviderException('guestbook', \sprintf('HTTP %d: %s', $answer->status, $answer->json()['message'] ?? ''), (string) $answer->status);
        }

        return $answer->json();
    }
}

Used on a MockHttpClient that answers p1 and a 404 otherwise:

$registry = new Registry([new GuestbookGatewayFactory($http)], ['guestbook' => ['factory' => 'guestbook', 'options' => ['token' => 'secret']]]);
$rating = $registry->rating('guestbook')->rating(new Place('p1'));
echo $rating->value, ' from ', $rating->count, ', ', $rating->url, "\n";
foreach ($registry->reviews('guestbook')->reviews(new Place('p1'), 3) as $review) {
    echo '  ', $review->rating, ' by ', $review->author, ' on ', $review->publishedAt->format('Y-m-d'), "\n";
}
echo implode(', ', $registry->get('guestbook')->capabilities()->gives()), "\n";
// then: rating(new Place('nope')), and (new GuestbookGatewayFactory($http))->create([])
4.8 from 52, https://guestbook.example/p1
  5 by Camille R. on 2026-10-01
rating, reviews, write-url
Omnireview\Exception\ProviderException: [guestbook] HTTP 404: Unknown place. (404)
Omnireview\Exception\InvalidConfigException: The "guestbook" gateway needs: token.

(run on 2026-10-26 in the harness's image, no network)

Registering it

  • Plain PHP: give the registry an instance (new Registry([new GuestbookGatewayFactory($http)], ...)).
  • Symfony: a service implementing GatewayFactoryInterface is autoconfigured with the tag omnireview.gateway_factory; with the application's autowiring, the class in src/ is enough. The bundle registers on its own only the three factories it knows (OmnireviewBundle::FACTORIES).
  • The harness: an entry in docker/harness/plugins.php (its slug, tests' namespace and factory), a block in docker/harness/config/gateways.php (its environment variables, example options and place) and its fixtures in docker/harness/config/recorded.php.

What the existing providers do

Read their code before writing one: GoogleGateway (two APIs behind one gateway, an OAuth refresh token kept until a minute before it lapses, Place::$owner selecting the owner's side), TripadvisorGateway (a key in the query, one details call kept for the rating and the write link), TrustpilotGateway (three hosts, a client-credentials token, a business user's header, a find() of its own).

Proposer une modification docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-26 with the harness's image against the workspace (the core and its three providers from their checkouts), PHP 8.4.27, PHPUnit 11.5.57, no network: 19 tests, 129 assertions, green.

  • The core: the registry's typed getters and their messages, a required option refused (RegistryTest); outOfFive(), allowsCaching(), gives().
  • The Symfony bundle: gateways built from configuration and injected by their argument's name; the installed providers' factories registered with the application's http_client (OmnireviewBundleTest, on a ContainerBuilder, not a kernel).
  • The Twig extension: the widget's origin, date, author link, escaped text, review and write links, no JSON-LD; the cache used for the platform's allowance and not for 0 or null; a platform down shows no rating (OmnireviewExtensionTest).
  • Plain PHP (BareTest, bin/bare): the registry built by hand, every provider asked a rating, reviews and a write link on recorded answers, no class of a framework loaded.
  • Each provider on MockHttpClient and fixtures written from its platform's reference: requests (URLs, headers, field masks, query, bodies, token grants), the mapping to the models, refused keys and unknown places (each provider's manual).

Every example of this manual marked as run was run that day with its output pasted: the first script, the Twig widget, an application test, a provider of its own.

What is not verified

  • No platform was called live: no Google Places key, no Business Profile access, no Tripadvisor Content API key, no Trustpilot application. Every recorded answer was written from the platform's published reference, not captured. The one address checked is Google's link to write a review, which answered 200 on 2026-10-08 (as its README states).
  • The Symfony bundle in a real kernel with TwigBundle (the template override, a cache pool), and the Laravel service provider of Laravel: written from the code.
  • Tripadvisor's Caching Policy is not public: nothing is kept until it is read.
  • Whether Trustpilot's Data Solutions API answers with a plain API key: Trustpilot's plans decide.
  • The known defects found while writing this manual: the typed getters did not check capabilities() (Usage), fixed with its test since; omnireview_write_url() and the widget not guarded against a platform down (fixed with its test since), the template override directory losing to the bundle's (Symfony and Twig, fixed since: the test checks where the template is and that no twig.paths is prepended, not a kernel with TwigBundle), Trustpilot's locale said to apply to invitations (Configuration, the docblock corrected since: an invitation keeps its own Invitation::$locale).

Responsibility

omnisense/omnireview and its omnireview/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by Google, Tripadvisor or Trustpilot. Each platform keeps its own terms - keys, display and attribution rules, caching limits, quotas - named with a link in each provider's README. The MIT licence covers this code only and grants no right to any service or to its data. Whoever installs and runs these packages is responsible for complying with those terms and for the use made of what they return.

Proposer une modification docs/verified.md