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.
Propose a change 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/omnireviewrequiressymfony/http-client-contracts(^3.0) and nothing else: no framework, no HTTP client of its own. It replacesomniscient/omnireviewandglitchr/omnireview: acomposer.jsonthat still requires an old name resolves to this package.- Each provider requires
omnisense/omnireviewandsymfony/http-client(6.4, 7 or 8). Its factory takes anHttpClientInterface(the application's, aMockHttpClientin a test) and makes its own withHttpClient::create()when given none. - The Symfony bundle needs
symfony/config,symfony/dependency-injectionandsymfony/http-kernel; the Twig functionstwig/twig, andsymfony/cache-contractsto 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()andwriteUrl()give the gateway typed by what is asked of it, or throwNotSupportedExceptionwhen 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
- 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. - 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 thanterms()->cacheFor. - Link to the platform to read them all and to write one (
writeUrl()). - 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 fromconfig/packages/omnireview.yamland registers the Twig functions (Symfony and Twig). - Laravel: no bridge; a service provider of the application builds the registry (Laravel).
Propose a change 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 $tripadvisorcompiles, and fails with PHP'sTypeErrorwhen the service is built (Tripadvisor's gateway implements noReplyInterface). Ask the registry ($registry->replies('tripadvisor')) for aNotSupportedExceptionthat 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>">, withdata-nosnippetwhenterms.noIndexis 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; thenterms.attributionlinked toRating::$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, shownd/m/Y), the platform's picture of its score orrating/scale, its title, its text escaped within quotation marks, the establishment's reply, andterms.attributionlinked 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 %}
Propose a change 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.
Propose a change 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 |
|
|---|---|---|---|---|---|
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).
Propose a change 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
| 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:
ratinganduserRatingCountof the place (X-Goog-FieldMask: id,displayName,rating,userRatingCount,googleMapsUri),urlitsgoogleMapsUri. For aPlacewith anownerand a gateway with the owner's access,averageRatingandtotalReviewCountof the Business Profile instead,urla Google Maps search link of the place. - Tripadvisor:
rating,num_reviews,web_url,rating_image_urlof/detailsin the gateway'slanguage. - Trustpilot:
score.trustScoreandnumberOfReviews.total,urltheprofileUrlof 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.
The link to write a review
$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).
Propose a change 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 |
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);
Propose a change 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(or0for a score or a count) in the model. - A date the platform writes in a form PHP cannot read:
publishedAtnull. - In Twig,
UnreachableExceptionfromomnireview_rating()andomnireview_reviews():nulland[](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.
Propose a change 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'sArrayAdapterand a call counter to test what is kept, asTests/Bridge/OmnireviewExtensionTest.phpdoes; withnull, nothing is kept. - In a Symfony functional test, the registry is the public service
Omnireview\Registry; replace the HTTP client the factories receive by configuringframework.http_client.mock_response_factoryin 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) |
Propose a change 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).
Propose a change 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'shttp_clientto 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 becomeUnreachableException), a 401 or 403 anInvalidKeyException, another error aProviderException; capabilities()true to the configuration: a capability that needs an access the options do not hold isfalse, and its method throwsNotSupportedException::operation();terms()read in the platform's documentation, withsource: how long it may be kept (0,nullwhen the platform's policy is not public), the reviews per call, the attribution,noIndex, the other rules in words;- tests on
MockHttpClientwith the platform's answers inTests/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
GatewayFactoryInterfaceis autoconfigured with the tagomnireview.gateway_factory; with the application's autowiring, the class insrc/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 indocker/harness/config/gateways.php(its environment variables, example options and place) and its fixtures indocker/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).
Propose a change 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 aContainerBuilder, 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
0ornull; 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
MockHttpClientand 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 notwig.pathsis prepended, not a kernel with TwigBundle), Trustpilot'slocalesaid to apply to invitations (Configuration, the docblock corrected since: an invitation keeps its ownInvitation::$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.