Omnireach is one PHP contract for what outside tools know of a site's audience. The search engines
say which queries the site shows for, on which pages, at which position and with how many clicks;
the analytics tools say who visits, from where, landing on which page. An application asks every
tool the same question - a ReachQuery: a period of whole days, the dimensions to break it down
by, the metrics, exact filters, a limit - and gets the same answer back: a Report of Rows, in
one set of units whoever answered. Each tool is a package of its own (omnireach/search-console,
omnireach/matomo...) that turns the query into the tool's API calls.
$google = $registry->search('google'); // a search engine: SearchPerformanceInterface
$stats = $registry->audience('stats'); // an analytics tool: AudienceInterface
$google->searchPerformance(ReachQuery::lastDays(28, [Dimension::Query])); // queries, clicks, position
$stats->audience(ReachQuery::lastDays(28, [Dimension::EntryPage])); // where visits start
$stats->audience(new ReachQuery($from, $to, [Dimension::Date], [Metric::Visits])); // a day-by-day history
The design is the one of the other Omnisense families: a provider's factory fills a
configuration with its name, its required options and the defaults of the others, then builds the
provider; a registry holds the application's providers by name. A provider says what it
can be asked in its capabilities (its dimensions and metrics, whether dimensions combine, its
terms and limits), and a query it cannot answer is refused before any call. The core needs no
framework: it requires symfony/http-client-contracts and nothing else, and every provider takes
the application's HTTP client in its factory's constructor.
Published as omnisense/omnireach (it replaces the former name omniscient/omnireach). The
namespace is Omnireach\; providers keep the omnireach/ vendor and the Omnireach\<Provider>\
namespace.
When to use it
- A site taken over from someone else: its search history and its audience over any period the tools still keep, before and after a redesign, page by page.
- A back office that shows the same "queries, pages, visits" panels whichever tool the site uses (Search Console or Bing for search, Matomo, Plausible or Google Analytics for the audience), or several at once.
- Checking what became of an old address: the queries and positions of one page
(
filters: ['page' => 'https://example.org/old-page']), the visits that still land on it.
It is not a visit counter of its own (nothing is counted on the site), not a tracker (no script is added to a page) and not a rank checker scraping search results: every figure comes from a tool's reporting API, with the site owner's own access. It stores nothing either: caching a report is the application's job, and the tools' quotas make it worth doing.
Providers
Five packages. The dimensions and metrics are each provider's capabilities(); "one at a time"
means its combined is false: one dimension besides the date.
| Package | Factory name | Contract | Dimensions | Metrics | Credentials |
|---|---|---|---|---|---|
omnireach/search-console |
search-console |
search performance | query, page, country, device, date (combined) | clicks, impressions, ctr, position | a service account's key, or an access token |
omnireach/bing-webmaster |
bing-webmaster |
search performance | query or page, date (one at a time) | clicks, impressions, ctr, position | an API key |
omnireach/matomo |
matomo |
audience | entry page, page, source, country or device, date (one at a time) | visitors, visits, pageviews, bounce rate, visit duration | a token_auth, or none for public reports |
omnireach/plausible |
plausible |
audience | page, entry page, source, country, device, date (combined) | visitors, visits, pageviews, bounce rate, visit duration | a Stats API key |
omnireach/ga4 |
ga4 |
audience | page, entry page, source, country, device, date (combined) | visitors, visits, pageviews, bounce rate, visit duration | a service account's key, or an access token |
No provider answers both contracts; the core's test provider, Omnireach\Testing\FixedReach,
does (Testing). Each provider's own manual lists its options, the calls it makes and
how it maps the tool's answer.
How this manual is organised
- Installation: requirements, a first script in plain PHP, a provider's API.
- Symfony and Laravel: wiring the registry in a framework (no bridge yet: a service definition of the application's).
- Concepts: providers, capabilities, factories, the registry, the query, the dimensions and metrics, rows and reports.
- Usage: search performance, audience.
- Configuration reference: the registry's configuration and every provider's options.
- Errors: every exception and when it is thrown.
- Testing: the fixed provider, the recorded HTTP client, the load check.
- The Docker harness: the core and every provider installed, its commands.
- Writing a provider.
- Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omnireach omnireach/matomo symfony/http-client # one package per provider
Requirements
- PHP 8.2 or later.
omnisense/omnireachrequiressymfony/http-client-contracts(^3.0) and nothing else.- Every provider requires the core and
symfony/http-client-contractsonly: the HTTP client itself is the application's. Any implementation ofSymfony\Contracts\HttpClient\HttpClientInterfacedoes -symfony/http-client(a library that stands alone and loads no framework) withHttpClient::create(), or aMockHttpClientin a test. A factory given no client refuses to build its provider (InvalidConfigException:The "matomo" provider needs an HTTP client: give one to MatomoFactory.); none makes a client of its own. omnireach/search-consoleandomnireach/ga4requireext-openssl: they sign the JWT a Google service account's token is asked with.
Omnireach runs the same in plain PHP, in a worker, in Laravel and in Symfony; there is no bundle or Laravel provider yet (Symfony, Laravel).
Plain PHP
A whole script with no provider and no network: the core's FixedReach answers from rows it was
given, as a provider would have returned them. It shows the pieces every provider shares.
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnireach\Exception\NotSupportedException;
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
use Omnireach\Model\Row;
use Omnireach\Registry;
use Omnireach\Testing\FixedReachFactory;
// Rows a provider would have returned: search queries by day, visits by entry page and source.
$search = [
new Row(['query' => 'atelier poterie strasbourg', 'date' => '2026-10-20'], ['clicks' => 12, 'impressions' => 340, 'ctr' => 0.0353, 'position' => 4.2]),
new Row(['query' => 'cours de poterie', 'date' => '2026-10-21'], ['clicks' => 3, 'impressions' => 410, 'ctr' => 0.0073, 'position' => 11.8]),
new Row(['query' => 'poterie', 'date' => '2026-09-01'], ['clicks' => 1, 'impressions' => 90, 'ctr' => 0.0111, 'position' => 23.0]),
];
$audience = [
new Row(['entry_page' => '/', 'source' => 'google'], ['visitors' => 210, 'visits' => 260, 'pageviews' => 700]),
new Row(['entry_page' => '/ateliers', 'source' => 'google'], ['visitors' => 95, 'visits' => 110, 'pageviews' => 300]),
new Row(['entry_page' => '/ateliers', 'source' => 'direct'], ['visitors' => 20, 'visits' => 25, 'pageviews' => 40]),
];
$registry = new Registry([new FixedReachFactory($search, $audience)], [
'site' => ['factory' => 'fixed', 'options' => ['site' => 'https://atelier.example.org/']],
]);
$october = [new DateTimeImmutable('2026-10-01'), new DateTimeImmutable('2026-10-31')];
// Search queries in October (the September row is outside the period).
$report = $registry->search('site')->searchPerformance(new ReachQuery(...$october, dimensions: [Dimension::Query]));
foreach ($report->rows as $row) {
printf("%-28s %3d clicks %4d impressions position %4.1f\n", $row->dimension(Dimension::Query), $row->metric(Metric::Clicks), $row->metric(Metric::Impressions), $row->metric(Metric::Position));
}
echo 'totals: ', json_encode($report->totals()), "\n\n";
// Visits by entry page, from Google only.
$entries = $registry->audience('site')->audience(new ReachQuery(...$october, dimensions: [Dimension::EntryPage], metrics: [Metric::Visits], filters: ['source' => 'google']));
foreach ($entries->rows as $row) {
printf("%-10s %d visits\n", $row->dimension(Dimension::EntryPage), $row->metric(Metric::Visits));
}
// The last 28 whole days, as seen on 2026-10-26: yesterday is the last.
$last = ReachQuery::lastDays(28, today: new DateTimeImmutable('2026-10-26'));
echo "\nlastDays(28): ", $last->start->format('Y-m-d'), ' to ', $last->end->format('Y-m-d'), "\n";
// What a provider's capabilities refuse, before any call.
$registry2 = new Registry([new Omnireach\Matomo\MatomoFactory(new Omnireach\Testing\RecordedHttpClient(static fn () => [500, '']))], [
'stats' => ['factory' => 'matomo', 'options' => ['url' => 'https://stats.example.org', 'site' => '1']],
]);
try {
$registry2->audience('stats')->audience(new ReachQuery(...$october, dimensions: [Dimension::Query]));
} catch (NotSupportedException $e) {
echo $e->getMessage(), "\n";
}
try {
$registry2->search('stats');
} catch (NotSupportedException $e) {
echo $e->getMessage(), "\n";
}
$ php bare.php
atelier poterie strasbourg 12 clicks 340 impressions position 4.2
cours de poterie 3 clicks 410 impressions position 11.8
totals: {"clicks":15,"impressions":750}
/ 260 visits
/ateliers 110 visits
lastDays(28): 2026-09-28 to 2026-10-25
Matomo does not report the dimension "query".
The "stats" provider (Omnireach\Matomo\MatomoProvider) does not report search performance.
(run on 2026-10-26 in the harness, PHP 8.4, with the harness's autoloader; the
Matomo part needs omnireach/matomo installed)
The pieces:
- a factory per provider package (
FixedReachFactory,MatomoFactory,SearchConsoleFactory...), given the HTTP client to call with; - the registry, built from the factories and the providers' options, by name:
get(), and the typedsearch()andaudience(), which refuse a provider that does not answer that contract; - the query: whole days, both included, dimensions, metrics (none: the provider's defaults),
exact filters, a limit;
ReachQuery::lastDays()ends yesterday, since no tool has today's figures complete; - the report: rows of dimensions and metrics in the family's units,
totals()of the metrics that add up (clicks, impressions, visits, pageviews - not the averages, not distinct visitors).
The query asking Matomo for search queries was refused by its capabilities: no HTTP call was made (the client given would have answered 500).
A provider's API
Matomo publishes a demo whose reports anyone may read, so this one runs without an account:
use Omnireach\Matomo\MatomoFactory;
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
use Omnireach\Registry;
use Symfony\Component\HttpClient\HttpClient;
$registry = new Registry([new MatomoFactory(HttpClient::create(['timeout' => 30]))], [
// Matomo's public demo: site 1, reports anyone may view (token "anonymous", the default).
'demo' => ['factory' => 'matomo', 'options' => ['url' => 'https://demo.matomo.cloud', 'site' => '1']],
]);
$matomo = $registry->audience('demo');
$week = ReachQuery::lastDays(7);
echo $week->start->format('Y-m-d'), ' to ', $week->end->format('Y-m-d'), "\n";
$site = $matomo->audience(new ReachQuery($week->start, $week->end, metrics: [Metric::Visits, Metric::Pageviews, Metric::BounceRate, Metric::VisitDuration]));
echo json_encode($site->rows[0]->metrics), "\n\n";
$entries = $matomo->audience(new ReachQuery($week->start, $week->end, [Dimension::EntryPage], [Metric::Visits, Metric::BounceRate], limit: 5));
foreach ($entries->rows as $row) {
printf("%-40s %6d visits %4.1f%% bounce\n", $row->dimension(Dimension::EntryPage), $row->metric(Metric::Visits), 100 * $row->metric(Metric::BounceRate));
}
$ php matomo-demo.php
2026-10-19 to 2026-10-25
{"visits":98616,"pageviews":192623,"bounce_rate":0.6655,"visit_duration":180.2}
/cart/ 2669 visits 49.1% bounce
/jobs 5688 visits 52.2% bounce
/ 3342 visits 47.4% bounce
/checkout/ 1300 visits 61.4% bounce
/diving/bali 3813 visits 73.9% bounce
(run live on 2026-10-26, 14:49 UTC, in the harness; two calls to demo.matomo.cloud)
The rows come in the order Matomo gives them, not sorted by visits; sort them in the application
when the order matters (Audience). The other providers need an account and a
key: their manuals show the same calls with their options. Building a provider calls nothing; a
missing required option is reported by create() as an InvalidConfigException naming it
(The "plausible" provider needs: api_key.).
In a framework
- Symfony: no bundle yet; the registry is a service of the application's (Symfony).
- Laravel: no provider in omnibridge/laravel yet; a service provider of the application's builds the registry once (Laravel).
- Any other: build the
Registryonce, where the framework builds its services, as the scripts above do.
Propose a change docs/installation.md
Symfony
There is no Omnireach bundle, and omnibridge/symfony has no Omnireach wiring yet: a Symfony
application declares the registry as a service of its own. Every factory takes the HTTP client as
its one constructor argument (?Symfony\Contracts\HttpClient\HttpClientInterface), which
autowiring fills with the application's http_client service (FrameworkBundle's http_client
section, enabled by default when symfony/http-client is installed).
The registry as a service
# config/services.yaml
services:
_defaults:
autowire: true
Omnireach\SearchConsole\SearchConsoleFactory: ~
Omnireach\Matomo\MatomoFactory: ~
Omnireach\Registry:
arguments:
$factories:
- '@Omnireach\SearchConsole\SearchConsoleFactory'
- '@Omnireach\Matomo\MatomoFactory'
$config:
google:
factory: search-console
options:
site: 'sc-domain:example.org'
credentials: '%env(resolve:GOOGLE_SERVICE_ACCOUNT)%'
stats:
factory: matomo
options:
url: 'https://stats.example.org'
site: '1'
token: '%env(MATOMO_TOKEN)%'
# .env.local, or bin/console secrets:set
GOOGLE_SERVICE_ACCOUNT=%kernel.project_dir%/config/secrets/search-console.json # the key's path, or its JSON
MATOMO_TOKEN=...
credentials takes the service account's JSON itself or the path of its file: a secret holding
the JSON works as well as a path. The factories can also be gathered by a tag rather than listed:
services:
_instanceof:
Omnireach\ProviderFactoryInterface:
tags: ['omnireach.factory']
Omnireach\Registry:
arguments:
$factories: !tagged_iterator omnireach.factory
$config: '%omnireach.providers%'
with the factories declared as services (or loaded by a resource: line) and the providers'
configuration in a parameter omnireach.providers of the same shape as $config above.
Using it
use Omnireach\Model\Dimension;
use Omnireach\Model\ReachQuery;
use Omnireach\Registry;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class SearchPanel
{
public function __construct(private readonly Registry $reach, private readonly CacheInterface $cache)
{
}
/** @return array<string, mixed> */
public function queries(): array
{
return $this->cache->get('reach.queries.28', function (ItemInterface $item): array {
$item->expiresAfter(6 * 3600);
return $this->reach->search('google')->searchPerformance(ReachQuery::lastDays(28, [Dimension::Query]))->toArray();
});
}
}
Nothing is built when the container compiles: a provider is built, and its required options
checked, the first time the registry is asked for it (get(), search(), audience()), then kept
for the rest of the request. A report is not cached by Omnireach: the tools' quotas (Search
Console's load quotas, Plausible's 600 requests an hour) make the application's cache worth it, as
above; Report::toArray() gives a plain array to keep.
There is no command, event, Twig function or route: the registry is a service, used from the application's controllers, message handlers and commands.
This page was written from the classes' constructors; it was not run in a Symfony kernel.
Propose a change docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnireach provider yet. The
registry is plain PHP: a service provider of the application's builds it once, from a config file,
with a symfony/http-client client (the factories take Symfony's HTTP client contracts; Laravel's
own HTTP client is Guzzle's, which they do not take).
// config/omnireach.php
return [
'providers' => [
'google' => ['factory' => 'search-console', 'options' => [
'site' => 'sc-domain:example.org',
'credentials' => storage_path('app/secrets/search-console.json'),
]],
'stats' => ['factory' => 'plausible', 'options' => [
'api_key' => env('PLAUSIBLE_API_KEY'),
'site' => 'example.org',
]],
],
];
// app/Providers/OmnireachServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnireach\Plausible\PlausibleFactory;
use Omnireach\Registry;
use Omnireach\SearchConsole\SearchConsoleFactory;
use Symfony\Component\HttpClient\HttpClient;
final class OmnireachServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function (): Registry {
$http = HttpClient::create(['timeout' => 30]);
return new Registry([
new SearchConsoleFactory($http),
new PlausibleFactory($http),
], config('omnireach.providers'));
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the
registry where it is needed:
use Illuminate\Support\Facades\Cache;
use Omnireach\Model\Dimension;
use Omnireach\Model\ReachQuery;
use Omnireach\Registry;
final class AudienceController
{
public function entries(Registry $reach)
{
return Cache::remember('reach.entries.28', now()->addHours(6), fn () => $reach->audience('stats')
->audience(ReachQuery::lastDays(28, [Dimension::EntryPage]))
->toArray());
}
}
List in the provider every factory the application uses: a hand-built registry knows only the
factories it is given. Configuration caching (php artisan config:cache) keeps working: the
options are plain arrays read through config(). This page was written from the registry's API;
it was not run in a Laravel application.
Propose a change docs/laravel.md
Concepts
Providers and contracts
Omnireach\ProviderInterface is one outside tool, configured. What it can be asked comes from the
contracts it implements, one per capability:
| Interface | Method | Default metrics | Implemented by |
|---|---|---|---|
SearchPerformanceInterface |
searchPerformance(ReachQuery $query): Report |
clicks, impressions, ctr, position | search-console, bing-webmaster |
AudienceInterface |
audience(ReachQuery $query): Report |
visitors, visits, pageviews | matomo, plausible, ga4 |
Both extend ProviderInterface:
| Method | Returns |
|---|---|
getName(): string |
the factory's name: search-console, matomo... - also the provider of every report it returns |
getTitle(): string |
the tool's display name: Google Search Console, Matomo |
capabilities(): Capabilities |
what it reports, its terms and limits (below) |
"Default metrics" are the ones a report counts when the query names none
(ReachQuery::metricsOr()). Both report methods throw NotSupportedException for a dimension, a
filter or a metric the provider does not report, before any call; every other failure is one of
the exceptions of Errors.
Capabilities
Omnireach\Capabilities (a readonly value) says what a provider is before it is asked anything:
| Property | Type | Default | |
|---|---|---|---|
searchPerformance |
bool |
false |
it implements SearchPerformanceInterface |
audience |
bool |
false |
it implements AudienceInterface |
dimensions |
list<Dimension> |
[] |
the dimensions it breaks a report down by (and filters on) |
metrics |
list<Metric> |
[] |
the metrics it counts |
combined |
bool |
true |
dimensions combine freely; false: one dimension at a time besides the date |
terms |
list<string> |
[] |
URLs of the terms whoever runs it must follow |
limits |
list<string> |
[] |
its rate limits, quotas and data freshness, in words |
| Method | |
|---|---|
contracts(): list<string> |
['searchPerformance'], ['audience'], or both |
check(ReachQuery $query, list<Metric> $metrics, string $provider): void |
throws NotSupportedException when the query asks for a dimension or a filter outside dimensions, a metric outside metrics, or - with combined false - more than one dimension besides the date |
toArray(): array |
the same as plain values (enums as their strings), for a page that lists the providers |
Every provider starts its report with check(), so a refusal costs no call. The messages name what
is missing: Matomo does not report the dimension "query"., Bing Webmaster reports one dimension at a time besides the date, not query and page.
Factory and Config
Omnireach\ProviderFactoryInterface builds a provider from options:
| Method | |
|---|---|
getName(): string |
the name providers are configured with: search-console, matomo... |
create(array $options = []): ProviderInterface |
a provider, its options checked |
Every provider's factory extends Omnireach\ProviderFactory, which does the work:
createConfig($options)- aConfig(anArrayObject) holding the options given, then the factory'spopulate()fills each key the options did not set with its default;- the keys listed under
omnireach.required_optionsare checked: one missing,''or[]is anInvalidConfigExceptionlisting every missing key (The "bing-webmaster" provider needs: api_key, site.); - the factory's
build()returns the provider.
getName() is read from the Config (omnireach.factory_name), so it costs nothing. The reserved
keys a factory sets:
| Key | |
|---|---|
omnireach.factory_name |
the provider's name |
omnireach.factory_title |
the provider's title |
omnireach.required_options |
the option names that must be filled |
Config reads options with types:
| Method | |
|---|---|
defaults(array $defaults): self |
sets each key not set yet |
validateNotEmpty(list<string> $keys): void |
step 2 above |
get(string $key, mixed $default = null): mixed |
the value as it is |
string(string $key): ?string |
a scalar as a string; null when it is empty |
int(string $key, int $default = 0): int, float(string $key, float $default = 0.0): float |
a numeric value, else the default |
bool(string $key): bool |
true, or "1", "true", "yes", "on" (an environment variable's spelling) |
Factories take their HTTP client in their constructor and call nothing when they build: a provider reached over HTTP makes its first call when it is first asked for a report (Google's providers ask for their token then).
Registry
Omnireach\Registry holds the application's providers by name:
$registry = new Registry(
[new SearchConsoleFactory($http), new MatomoFactory($http)], // iterable of factories, keyed by getName()
[
'google' => ['factory' => 'search-console', 'options' => ['site' => 'sc-domain:example.org', 'credentials' => '/secrets/sa.json']],
'stats' => ['factory' => 'matomo', 'options' => ['url' => 'https://stats.example.org', 'site' => '1', 'token' => '...']],
],
);
| Method | |
|---|---|
get(string $name): ProviderInterface |
the provider, built on first use from its factory and options, then kept |
search(string $name): SearchPerformanceInterface |
the same, or NotSupportedException when it is not a search engine |
audience(string $name): AudienceInterface |
the same, or NotSupportedException when it is not an analytics tool |
has(string $name): bool |
whether a provider of that name is configured |
options(string $name): array |
the options it is configured with ([] when none) |
names(): list<string> |
the configured providers' names |
factories(): list<string> |
the factories installed: what factory: may name |
A name not configured, or configured with a factory not installed, is an InvalidConfigException
(No "y" audience provider; configured: x., No "matomo" factory for the "x" audience provider; installed: none.). The provider's name is the application's (google, stats); the factory's
name is the package's. Two providers may use the same factory with different options: two
Matomo sites, the Search Console properties of two domains. The query can also name another site
than the configured one (ReachQuery::$site).
The query
Omnireach\Model\ReachQuery (readonly) is what a report is asked for:
new ReachQuery(
\DateTimeImmutable $start,
\DateTimeImmutable $end,
array $dimensions = [], // list<Dimension>
array $metrics = [], // list<Metric>; none: the provider's defaults
array $filters = [], // array<string, string>, by Dimension value
int $limit = 1000,
?string $site = null, // another site than the configured one
);
| Property | Type | |
|---|---|---|
start, end |
\DateTimeImmutable |
the period's first and last days, both included, set to midnight (in the timezone of the dates given) |
dimensions |
list<Dimension> |
what the rows are broken down by, in that order; none: one row for the whole period |
metrics |
list<Metric> |
what the rows count; none: the report's defaults |
filters |
array<string, string> |
exact filters, keyed by a dimension's value: ['country' => 'FR', 'source' => 'google']; all must hold |
limit |
int |
at most this many rows, 1000 by default |
site |
?string |
the site, property or domain to report on, when not the provider's site option |
The constructor throws Omnireach\Exception\InvalidArgumentException when the period ends before
it starts, when limit is below 1, or when a filter names no dimension (No "city" dimension to filter on.).
| Method | |
|---|---|
static lastDays(int $days, array $dimensions = [], array $metrics = [], ?\DateTimeImmutable $today = null): self |
the last $days whole days (at least 1), yesterday the last: today's figures are complete nowhere. $today for a test |
has(Dimension $d): bool |
whether the query breaks down by it |
filter(Dimension $d): ?string |
the filter's value on it, if any |
metricsOr(list<Metric> $defaults): list<Metric> |
the query's metrics, or the defaults when it names none |
breakdown(): list<Dimension> |
the dimensions but the date: what combined: false counts |
apply(iterable<Row> $rows): list<Row> |
the filters and the limit put on rows (below) |
Filters and limit
Every provider passes the rows it read through $query->apply() before returning them, so a query
means the same everywhere, whether or not the tool filtered upstream. apply() keeps a row unless
it has a filtered dimension with another value - a row that does not carry the filtered dimension
(a report not broken down by it, where the tool applied the filter itself) is kept - and stops at
limit rows. The comparison is exact and case-sensitive, on the values in the family's units
(below): filter country with FR, device with mobile.
Dimensions
Omnireach\Model\Dimension, a string-backed enum; the value is the key of Row::$dimensions and of
ReachQuery::$filters.
| Case | Value | Meaning | Reported by |
|---|---|---|---|
Query |
query |
the search query typed | search engines |
Page |
page |
a page: shown in search results (search engines, a full URL), or viewed (analytics, a path) | all |
EntryPage |
entry_page |
the page a visit started on, the landing page | analytics |
Country |
country |
the visitor's country: ISO 3166-1 alpha-2, upper case (FR); alpha-3 for Search Console (FRA) |
search-console, matomo, plausible, ga4 |
Device |
device |
desktop, mobile, tablet (lower case; Matomo also gives its other types) |
search-console, matomo, plausible, ga4 |
Source |
source |
where a visit came from: a search engine, a site, direct, as the tool names it |
analytics |
Date |
date |
the day, Y-m-d: a period's history |
all (Bing: its own weekly periods) |
Metrics
Omnireach\Model\Metric, a string-backed enum; the value is the key of Row::$metrics.
| Case | Value | Unit | Search | Adds up |
|---|---|---|---|---|
Clicks |
clicks |
clicks from search results to the site | yes | yes |
Impressions |
impressions |
times the site was shown in search results | yes | yes |
Ctr |
ctr |
clicks per impression, a ratio from 0 to 1 | yes | no |
Position |
position |
the average position in search results, 1 the first | yes | no |
Visitors |
visitors |
distinct visitors (users) | no | no |
Visits |
visits |
visits (sessions) | no | yes |
Pageviews |
pageviews |
pages viewed | no | yes |
BounceRate |
bounce_rate |
visits of one page only, a ratio from 0 to 1 | no | no |
VisitDuration |
visit_duration |
the average length of a visit, in seconds | no | no |
isSearch() is the "Search" column, isAdditive() the "Adds up" one: a sum of averages, ratios
or distinct counts over rows means nothing, so Report::totals() leaves them out. Values are int
for counts and float for ratios, positions and durations (Plausible's visit duration as
Plausible gives it). Ratios the providers compute or convert are rounded to 4 decimals, positions
and durations to 1.
A metric the tool does not give for a row is left out of that row, never set to zero:
$row->metric(Metric::Position) is null then.
Rows and reports
Omnireach\Model\Row (readonly) is one line of a report:
| Member | |
|---|---|
array<string, string> $dimensions |
by Dimension value: ['query' => 'atelier poterie', 'country' => 'FRA'] |
array<string, int|float> $metrics |
by Metric value: ['clicks' => 42, 'position' => 3.6] |
dimension(Dimension $d): ?string |
one dimension's value |
metric(Metric $m): int|float|null |
one metric's value |
toArray(): array |
['dimensions' => ..., 'metrics' => ...] |
Omnireach\Model\Report (readonly) is a provider's answer:
| Member | |
|---|---|
string $provider |
the provider's name: search-console, matomo... |
string $site |
the site, property or domain reported on (the query's, else the configured one) |
ReachQuery $query |
the query answered |
list<Row> $rows |
the rows, filtered and cut to the limit |
?string $note |
what the figures are not, when the provider knows: provisional days, Bing's weekly periods, Matomo's distinct visitors summed by day |
totals(): array<string, int|float> |
the sums of the metrics that add up, over the rows |
toArray(): array |
provider, site, start and end (Y-m-d), the dimensions' values, the rows, the note: a plain array to cache or to send as JSON |
The order of the rows is each provider's (Search performance, Audience): sort them in the application when it matters.
The HTTP layer
Providers call their tool through Omnireach\Http\JsonApi, which the core gives them:
new JsonApi(HttpClientInterface $http, string $provider), then
request(string $method, string $url, array $options = [], string $what = 'the request'): Data.
The options are the HTTP client's (query, json, body, headers, auth_bearer...). A
transport error becomes UnreachableException; a 401 or 403 InvalidKeyException; a 404
NotFoundException; any other status of 400 or more, or a 2xx body that is not JSON,
ProviderException - each with the tool's own message when the answer carries one, looked up
under error_description, error.message, message, Message, detail, errors.0.message,
errors.0.detail, fault.faultstring, then error (JsonApi::message()).
The answer is an Omnireach\Support\Data: the decoded JSON read with types at dotted paths
(string('rows.0.keys.1'), int(), float(), bool(), date(), list(), data(),
items(), raw(), has()), each giving null (or an empty list) when the answer holds something
else - so an answer of an unexpected shape gives empty rows rather than a \TypeError.
Propose a change docs/concepts.md
Search performance
SearchPerformanceInterface::searchPerformance(ReachQuery $query): Report reports how the site
shows in a search engine: the queries it appears for, its pages shown, their clicks, impressions,
click-through rate and average position. Two providers answer it: omnireach/search-console and
omnireach/bing-webmaster. Metrics by default: clicks, impressions, ctr, position.
The queries of a period
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
$report = $registry->search('google')->searchPerformance(ReachQuery::lastDays(28, [Dimension::Query]));
foreach ($report->rows as $row) {
printf(
"%-40s %5d clicks %7d impressions %5.1f%% CTR position %4.1f\n",
$row->dimension(Dimension::Query),
$row->metric(Metric::Clicks),
$row->metric(Metric::Impressions),
100 * ($row->metric(Metric::Ctr) ?? 0),
$row->metric(Metric::Position) ?? 0,
);
}
$totals = $report->totals(); // ['clicks' => ..., 'impressions' => ...]: what adds up
CTR and position do not add up over rows: totals() leaves them out. The CTR of the whole period
is $totals['clicks'] / $totals['impressions']; its position is a report with no dimension.
One page, before and after
The queries a page shows for, and its position day by day - an old address after a site was taken over, or a page rewritten:
$from = new \DateTimeImmutable('2026-03-01');
$to = new \DateTimeImmutable('2026-09-30');
$page = 'https://example.org/old-page';
$queries = $registry->search('google')->searchPerformance(
new ReachQuery($from, $to, [Dimension::Query], filters: ['page' => $page]),
);
$history = $registry->search('google')->searchPerformance(
new ReachQuery($from, $to, [Dimension::Date], [Metric::Clicks, Metric::Position], ['page' => $page]),
);
Search engines give pages as full URLs, as the property knows them: filter with the exact URL (scheme, host, trailing slash). Filters are exact and case-sensitive (Concepts).
Breakdowns and combinations
| Search Console | Bing Webmaster | |
|---|---|---|
| dimensions | query, page, country, device, date | query, page, date |
| combined | freely: [Query, Country], [Page, Device, Date] |
one at a time besides the date: [Query, Date] yes, [Query, Page] refused |
| filters | on any of its dimensions, sent to Search Console (equals, all together) |
only on the dimension the report is broken down by (query in a query report), applied to Bing's rows; any other refused |
| the date | each day | Bing's own periods (one row a week, about six months back), those inside the query's days |
| no dimension | one row for the period | the whole site (GetRankAndTrafficStats): clicks and impressions, no position |
| row order | as Search Console returns them (the code does not sort) | by clicks then impressions, most first; by the dimension's values with the date |
| largest report | pages of 25,000 rows up to limit |
what Bing returns, cut to limit |
Report::$note |
The last days are provisional (dataState "all"). with data_state: all |
always: Bing's weekly periods, positions weighted by impressions |
A query one provider cannot answer is refused by its capabilities before any call, with
NotSupportedException: check capabilities()->dimensions and ->combined first when the same
screen offers several providers, or catch it and show "not available from Bing".
use Omnireach\Exception\NotSupportedException;
$query = ReachQuery::lastDays(90, [Dimension::Query, Dimension::Country]);
foreach (['google', 'bing'] as $name) {
try {
$reports[$name] = $registry->search($name)->searchPerformance($query);
} catch (NotSupportedException $e) {
$reports[$name] = null; // "Bing Webmaster does not report the dimension "country"."
}
}
Countries and devices
Search Console gives countries as ISO 3166-1 alpha-3, upper-cased here (FRA, DEU): filter with
the same (['country' => 'FRA']; the filter is sent lower-cased, as Search Console writes them).
Devices are desktop, mobile, tablet. Bing reports neither.
History and freshness
The search engines keep a limited history: 16 months for Search Console, about six months for
Bing. A site taken over should have its history exported while it is still there - a report by
date, kept by the application. Search Console's last two or three days are provisional: with
data_state: final (the default) they are not in the report yet; ReachQuery::lastDays() ends
yesterday either way.
Propose a change docs/usage-search.md
Audience
AudienceInterface::audience(ReachQuery $query): Report reports who visits the site, as an
analytics tool counted it: visitors, visits, pages viewed, bounce rate and visit length, by entry
page, page, source, country, device or day. Three providers answer it: omnireach/matomo,
omnireach/plausible and omnireach/ga4. Metrics by default: visitors, visits, pageviews.
Where visits start
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
$report = $registry->audience('stats')->audience(
ReachQuery::lastDays(28, [Dimension::EntryPage], [Metric::Visits, Metric::BounceRate, Metric::VisitDuration]),
);
$rows = $report->rows;
usort($rows, static fn ($a, $b) => ($b->metric(Metric::Visits) ?? 0) <=> ($a->metric(Metric::Visits) ?? 0));
foreach ($rows as $row) {
printf(
"%-40s %6d visits %5.1f%% bounce %4.0f s\n",
$row->dimension(Dimension::EntryPage),
$row->metric(Metric::Visits),
100 * ($row->metric(Metric::BounceRate) ?? 0),
$row->metric(Metric::VisitDuration) ?? 0,
);
}
Rows come in the order the tool gives them; sort them when the order matters, as above (Matomo's demo answered entry pages in an order that is not by visits: Installation).
A history
$history = $registry->audience('stats')->audience(new ReachQuery(
new \DateTimeImmutable('2026-01-01'),
new \DateTimeImmutable('2026-09-30'),
[Dimension::Date],
[Metric::Visits, Metric::Pageviews],
limit: 366,
));
$series = array_map(static fn ($row) => [$row->dimension(Dimension::Date), $row->metric(Metric::Visits)], $history->rows);
With the date and another dimension, each day has a row per value: [Dimension::Source, Dimension::Date] gives each source's visits day by day. The limit counts rows, so a long history
by source needs a higher one than the default 1000.
Sources, countries, devices
$query = new ReachQuery($from, $to, [Dimension::EntryPage], [Metric::Visits], ['source' => 'google', 'country' => 'FR']);
$fromGoogleInFrance = $registry->audience('stats')->audience($query);
$sources = $registry->audience('stats')->audience(ReachQuery::lastDays(28, [Dimension::Source]));
Filters are exact and case-sensitive, on the family's values: a country FR, a device mobile
(Matomo's included), a source as the tool names it - Matomo's referrer name, GA4's
sessionSource, Plausible's visit:source - so a report by source shows which values to filter
with. The query is readonly: build a new one for other filters, lastDays() takes none.
What each tool does
| Matomo | Plausible | Google Analytics 4 | |
|---|---|---|---|
| dimensions | entry page, page, source, country, device, date | page, entry page, source, country, device, date | page, entry page, source, country, device, date |
| combined | one at a time besides the date | freely | freely |
| filters | source, country, device: a Matomo segment; page or entry page: only in a report broken down by it | any of its dimensions (is) |
any of its dimensions (EXACT, an andGroup for several) |
| visitors over a range | Matomo's distinct visitors when the installation processes them, else the sum of each day's (Report::$note says so) |
distinct visitors | totalUsers |
| pageviews of entry pages | the actions of the visits that started there (entry_nb_actions) |
pageviews | screenPageViews |
| bounce rate | bounces over visits | Plausible's percent divided by 100 | bounceRate (GA4's definition: sessions not engaged) |
| largest report | Matomo's filter_limit (= limit, per day in a history), cut to limit |
pages of 10,000 up to limit |
pages of 250,000 up to limit |
| row order | Matomo's | Plausible's | GA4's; by date when the date is asked |
The same name does not always mean the same count: a GA4 "session" and a Matomo "visit" end on different rules, a GA4 bounce is a session that was not engaged. Compare a site with itself over time, not one tool with another.
Devices
Plausible and GA4 give desktop, mobile, tablet. Matomo has more types: its smartphones,
phablets and feature phones become one mobile row (their counts summed), and a device filter
mobile is sent to Matomo as those three types; every other type is Matomo's own label lower-cased
(desktop, tablet, console, tv, portable media player, unknown...), and types with no
visit are kept as rows of 0.
Pages, before and after
The visits a page still receives after a redesign - an address to redirect, a page to keep:
$old = $registry->audience('stats')->audience(new ReachQuery(
new \DateTimeImmutable('2026-01-01'),
new \DateTimeImmutable('2026-09-30'),
[Dimension::EntryPage, Dimension::Date],
[Metric::Visits],
['entry_page' => '/ancienne-page'],
));
Analytics tools give pages as paths (/ateliers), Matomo's with a leading slash added when its
label has none. With Matomo, a filter on a page works only in a report broken down by that page
(as above); filtered by page in a report by country, it is refused (NotSupportedException).
Propose a change docs/usage-audience.md
Configuration reference
The registry's configuration
new Registry($factories, $config) takes the providers by name, each with its factory and options:
[
'<provider name>' => [
'factory' => '<factory name>', // required: a factory's getName()
'options' => [/* ... */], // optional, default []: handed to the factory's create()
],
]
The provider name is the application's (google, bing, stats); the factory name is the
package's. Options are checked when the provider is first asked for, not when the registry is
built: a required option missing, '' or [] is an InvalidConfigException naming it.
Every factory takes the HTTP client in its constructor - new MatomoFactory($http) - and refuses
to build without one. Options read as strings accept any scalar (a site id given as an integer
included); an empty string is the same as an option not given.
omnireach/search-console
Factory Omnireach\SearchConsole\SearchConsoleFactory, name search-console.
| Option | Type | Default | Required | |
|---|---|---|---|---|
site |
string | - | yes | the property: sc-domain:example.org (a domain property) or https://example.org/ (a URL-prefix property) |
credentials |
string | '' |
one of the two | a service account's JSON key, or the path of its file; the account added as a user of the property |
access_token |
string | '' |
one of the two | a token with the webmasters.readonly scope, obtained elsewhere; used as it is, never refreshed |
data_state |
string | final |
no | final: complete days only; all: the last days too, provisional (the report's note says so) |
url |
string | https://searchconsole.googleapis.com/webmasters/v3 |
no | the API's base URL, trailing slash removed |
With neither credentials nor access_token, create() throws InvalidConfigException (A Google API needs a service account's key or an access token.); credentials that are neither a key
nor the path of one too. With both, the access token is used and the key never.
omnireach/bing-webmaster
Factory Omnireach\BingWebmaster\BingWebmasterFactory, name bing-webmaster.
| Option | Type | Default | Required | |
|---|---|---|---|---|
api_key |
string | - | yes | Bing Webmaster Tools, Settings, API access |
site |
string | - | yes | the site as verified there: https://example.org/ |
url |
string | https://ssl.bing.com/webmaster/api.svc/json |
no | the JSON endpoint's base URL |
omnireach/matomo
Factory Omnireach\Matomo\MatomoFactory, name matomo.
| Option | Type | Default | Required | |
|---|---|---|---|---|
url |
string | - | yes | the installation: https://stats.example.org, https://example.matomo.cloud (trailing slash removed; /index.php is added) |
site |
string | - | yes | the site's id (idSite) |
token |
string | anonymous |
no | a token_auth with view access; anonymous for reports anyone may view |
omnireach/plausible
Factory Omnireach\Plausible\PlausibleFactory, name plausible.
| Option | Type | Default | Required | |
|---|---|---|---|---|
api_key |
string | - | yes | a Stats API key |
site |
string | - | yes | the site's domain as Plausible knows it: example.org |
url |
string | https://plausible.io |
no | plausible.io, or a self-hosted Community Edition |
omnireach/ga4
Factory Omnireach\Ga4\Ga4Factory, name ga4.
| Option | Type | Default | Required | |
|---|---|---|---|---|
property |
string | - | yes | the property's numeric id (Admin, Property details), not the G- measurement id |
credentials |
string | '' |
one of the two | a service account's JSON key, or the path of its file; the account given the Viewer role on the property |
access_token |
string | '' |
one of the two | a token with the analytics.readonly scope, obtained elsewhere |
url |
string | https://analyticsdata.googleapis.com/v1beta |
no | the Data API's base URL |
Per query
What a report covers is the query's, not the provider's options: the period, the dimensions, the
metrics, the filters, the limit (Concepts). ReachQuery::$site, when set,
replaces the provider's site (or property) for that report only.
The fixed provider (testing)
Omnireach\Testing\FixedReachFactory, name fixed, takes its rows in its constructor
(new FixedReachFactory($searchRows, $audienceRows)) and one option:
| Option | Type | Default | Required | |
|---|---|---|---|---|
site |
string | https://example.org/ |
no | the site its reports name |
Propose a change docs/configuration.md
Errors
Every exception Omnireach throws implements Omnireach\Exception\OmnireachException (a
\Throwable):
| Exception | Extends | When |
|---|---|---|
InvalidArgumentException |
\InvalidArgumentException |
a query no report can answer: the period ends before it starts, a limit below 1, a filter on no dimension |
InvalidConfigException |
\InvalidArgumentException |
a provider not configured, a factory not installed, a required option missing or empty, a factory given no HTTP client, Google credentials missing or unreadable |
NotSupportedException |
\LogicException |
a provider asked for what it cannot do: a dimension, a filter or a metric it does not report, dimensions it cannot combine, search performance from an analytics tool (Registry::search()) or the reverse. Thrown before any call |
ProviderException |
\RuntimeException |
the tool answered, and not with what was asked: an error status, a body that is not JSON, an error inside a 200 (Matomo), no token from Google |
InvalidKeyException |
ProviderException |
the tool refused the credentials: 401 or 403, or Matomo's "access"/"token" error |
NotFoundException |
ProviderException |
nothing under that identifier: 404 (a site or property that does not exist, or not this account's) |
UnreachableException |
\RuntimeException |
the tool could not be reached: a network error, a timeout |
ProviderException's code is the HTTP status (200 for an error carried in a 200 answer or a body
that is not JSON); UnreachableException's is 0 and its previous exception a copy of the HTTP
client's.
UnreachableException's message names the method and the path called, and the HTTP client's
reason without the address it quotes (JsonApi::reason()): never the query string, where Bing's
API key travels. The previous exception is a copy of the client's (Http\Redaction::copy()): the
same class and code, the message with the URL's key parameters and password masked (apikey=***),
its own previous copied the same way. Neither keeps its trace's arguments, where the request's
query, headers and bearer token stood: a log that writes the whole chain, arguments included
(zend.exception_ignore_args off), holds no key. Nor does an error answered (a refused key, nothing there, a server error, a body
that is not JSON): every exception JsonApi::request() throws leaves its trace's arguments out.
The messages
Seen in the harness on 2026-10-26, Plausible's provider on recorded answers, Bing's on a host that does not exist (its line as the message reads since 2026-10-29, the key no longer in it):
401 InvalidKeyException (code 401): Plausible refused the credentials (401) for the query: Invalid API key or site ID
404 NotFoundException (code 404): Plausible has no the query: not found
429 ProviderException (code 429): Plausible answered 429 for the query: Too many requests
500 html ProviderException (code 500): Plausible answered 500 for the query.
200 html ProviderException (code 200): Plausible answered 200 for the query: an answer that is not JSON
network UnreachableException: Bing Webmaster could not be reached for GetQueryStats (GET /json/GetQueryStats): Could not resolve host: bing.invalid.
query InvalidArgumentException: The period ends (2026-10-01) before it starts (2026-10-02).
query InvalidArgumentException: A report has at least one row: limit 0.
query InvalidArgumentException: No "city" dimension to filter on.
config InvalidConfigException: The "plausible" provider needs: api_key.
config InvalidConfigException: The "plausible" provider needs an HTTP client: give one to PlausibleFactory.
config InvalidConfigException: A Google API needs a service account's key or an access token.
config InvalidConfigException: The credentials are not a service account's JSON key (client_email, private_key), nor the path of one.
registry InvalidConfigException: No "y" audience provider; configured: x.
registry InvalidConfigException: No "matomo" factory for the "x" audience provider; installed: none.
The tool's own message is added after a colon when the answer carries one (Concepts); the tool's error code is not kept apart.
What to catch where
| Where | Catch |
|---|---|
| a dashboard panel | OmnireachException: show "unavailable" and the last report cached, log the message |
| a screen that offers several providers | NotSupportedException, or check capabilities() first: the provider cannot answer that query |
| a scheduled export | UnreachableException and a ProviderException of code 429 or 5xx: retry later; InvalidKeyException: alert someone, the key was revoked or lost its access |
| building the registry, a deploy | InvalidConfigException: a configuration error, to fix, not to retry |
| a form that builds a query | Omnireach\Exception\InvalidArgumentException: the period or the limit typed is wrong |
A tool's message is the tool's, untranslated: show a message of the application's, keep the tool's for the logs.
Propose a change docs/errors.md
Testing
Testing an application that reports with Omnireach. Writing a provider and its tests: Writing a provider.
The fixed provider as a fake
Omnireach\Testing\FixedReach answers both contracts from rows it was given, with no network; its
factory, FixedReachFactory, registers it under the name fixed. Configure the provider the
application uses with it in tests:
use Omnireach\Model\Row;
use Omnireach\Registry;
use Omnireach\Testing\FixedReachFactory;
$registry = new Registry([new FixedReachFactory(
search: [new Row(['query' => 'atelier poterie', 'date' => '2026-10-20'], ['clicks' => 12, 'impressions' => 340])],
audience: [new Row(['entry_page' => '/', 'date' => '2026-10-20'], ['visits' => 260])],
)], [
'google' => ['factory' => 'fixed'],
'stats' => ['factory' => 'fixed', 'options' => ['site' => 'https://atelier.example.org/']],
]);
What it does with a query:
- its capabilities are every dimension and every metric, combined: it refuses nothing but a metric or dimension that does not exist;
- a row with a
dateoutside the period is left out; a row without one is kept; - the query's filters and limit are applied (
ReachQuery::apply()); - each row is projected on the query's dimensions, and on its metrics when it names some. Rows are not summed: two rows of the same query on two days, asked by query only, stay two rows. Give it the rows as the test expects them;
- the report's
providerisfixed, itssitethe query's, else thesiteoption, elsehttps://example.org/.
A provider on recorded answers
To test the application's handling of a real tool's answers - and of its failures - give the
provider's factory Omnireach\Testing\RecordedHttpClient (or Symfony's MockHttpClient):
use Omnireach\Exception\InvalidKeyException;
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
use Omnireach\Model\Row;
use Omnireach\Plausible\PlausibleFactory;
use Omnireach\Registry;
use Omnireach\Testing\FixedReachFactory;
use Omnireach\Testing\RecordedHttpClient;
use PHPUnit\Framework\TestCase;
final class EntryPagesPanelTest extends TestCase
{
public function testTheBusiestEntryPages(): void
{
$registry = new Registry([new FixedReachFactory(audience: [
new Row(['entry_page' => '/', 'date' => '2026-10-20'], ['visits' => 260]),
new Row(['entry_page' => '/ateliers', 'date' => '2026-10-21'], ['visits' => 110]),
])], ['stats' => ['factory' => 'fixed']]);
$report = $registry->audience('stats')->audience(
ReachQuery::lastDays(28, [Dimension::EntryPage], [Metric::Visits], today: new DateTimeImmutable('2026-10-26')),
);
self::assertSame(['/', '/ateliers'], array_map(static fn (Row $r) => $r->dimension(Dimension::EntryPage), $report->rows));
self::assertSame(['visits' => 370], $report->totals());
}
public function testWhatPlausibleIsSent(): void
{
$http = RecordedHttpClient::routes([
'POST /api/v2/query' => '{"results":[{"dimensions":["/"],"metrics":[3644,54]}]}',
]);
$registry = new Registry([new PlausibleFactory($http)], [
'stats' => ['factory' => 'plausible', 'options' => ['api_key' => 'test-key', 'site' => 'example.org']],
]);
$report = $registry->audience('stats')->audience(new ReachQuery(
new DateTimeImmutable('2026-10-01'), new DateTimeImmutable('2026-10-31'),
[Dimension::EntryPage], [Metric::Visits, Metric::BounceRate],
));
self::assertSame(['visits' => 3644, 'bounce_rate' => 0.54], $report->rows[0]->metrics);
self::assertSame('test-key', $http->requests[0]['options']['auth_bearer']);
self::assertSame(['visit:entry_page'], $http->requests[0]['options']['json']['dimensions']);
}
public function testARevokedKey(): void
{
$http = RecordedHttpClient::routes(['POST /api/v2/query' => [401, '{"error":"Invalid API key"}']]);
$plausible = (new PlausibleFactory($http))->create(['api_key' => 'revoked', 'site' => 'example.org']);
$this->expectException(InvalidKeyException::class);
$plausible->audience(ReachQuery::lastDays(7));
}
}
$ vendor/bin/phpunit EntryPagesPanelTest.php
... 3 / 3 (100%)
OK (3 tests, 6 assertions)
(run on 2026-10-26 in the harness, PHPUnit 11.5, PHP 8.4)
RecordedHttpClient (shipped with the core; it needs no symfony/http-client):
| Member | |
|---|---|
new RecordedHttpClient(\Closure $answer) |
answers each request with $answer($method, $url, $options), which returns [status, body] or [status, body, headers] |
static routes(array $routes): self |
answers by route: "METHOD /path" => a body (status 200) or [status, body]; the first route whose method matches and whose path ends the URL's path answers; anything else gets a 404 {"error":"not recorded: ..."} |
array $requests |
every request made: ['method' => ..., 'url' => ..., 'options' => ...], the options as the provider gave them (json, body, query, auth_bearer...) |
stream() |
throws \LogicException: it does not stream (no provider streams) |
Its responses (RecordedResponse) never throw: a provider sees the status and the body, as with a
real client called with getContent(false). A network error cannot be recorded with it; use
MockHttpClient with a MockResponse whose error option is set, which JsonApi turns into an
UnreachableException.
The load check
Omnireach\Testing\LoadCheck is the check every package of the family runs in its BareTest: each
class of the package loaded in a PHP process of its own, then what came from outside.
| Member | |
|---|---|
LoadCheck::FOREIGN |
the classes no package may load: Symfony's but its HTTP client and service contracts and polyfills, Illuminate, Laravel, Doctrine, Twig, League, Guzzle, Nyholm, Http, Omnibridge, Base, App |
run(string $root, string $namespace, string $autoload, list<string> $extra = []): array |
runs bin/load-all in a child PHP: ['loaded' => classes, 'foreign' => foreign symbols declared, 'files' => files included], the files Composer includes at start for every package left out |
foreign(list<string> $symbols): list<string> |
the symbols matching FOREIGN |
outside(list<string> $files, string $root, list<string> $allowed): list<string> |
the files from outside the package and outside the allowed vendor fragments (/vendor/psr/...) |
autoload(string $class, string $test, string $root): ?string |
the autoloader that knows both the class and the test (the package's or the harness's); null when the package sits in an application's vendor/ |
An application rarely needs it; it is how the family proves that a provider pulls in no framework.
No conformance suite
Unlike Omnibus, the family has no shared contract test (Testing\*ContractTests): each provider's
tests check its own requests and mapping, its refusals and its factory
(Verified, and not).
In a framework's functional test
The registry is a service of the application's: in Symfony, define it with FixedReachFactory in
config/services_test.yaml, or replace the http_client service with a MockHttpClient in the
test environment; in Laravel, bind another Registry singleton in the test's setUp().
Propose a change docs/testing.md
The Docker harness
docker/ composes this core and every provider (docker/harness/plugins.php) into one
installation, PHP 8.4 with Composer, in the image omnisense/omnireach-harness:
cd docker
docker compose run --rm omnireach test # every package's tests, no network
docker compose run --rm omnireach live # the checks that need no account (Matomo's public demo)
docker compose run --rm omnireach bare # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omnireach phpstan # PHPStan at the highest level, each package
docker compose down -v # the harness's volume gone
What it installs
At each start, docker/harness/setup.php writes the harness's composer.json and phpunit.xml
in the volume (/harness), and the entrypoint runs composer install the first time, composer update when the composer.json changed:
- the core from its checkout (mounted at
/omnireach/core, a path repository, symlinked); - each provider from a checkout under
OMNIREACH_PLUGINSwhen one is there - set it to../..indocker/.env(git-ignored) when the core sits in the workspace beside the providers - else from GitHub (https://github.com/glitchr-omnisense/omnireach-<slug>.git, branch 1.x, a plain git clone); phpunit/phpunit11,phpstan/phpstan2.1 andsymfony/http-clientfor the tests;- one PHPUnit suite per package (
core,bing-webmaster,ga4,matomo,plausible,search-console) and a link/harness/packages/<slug>to each provider's directory.
The service uses Docker's default bridge network (no network of its own) and one named volume,
omnireach-harness_harness, which keeps the installation and Composer's cache between runs.
The commands
| Command | Runs |
|---|---|
test (the default) |
vendor/bin/phpunit --exclude-group live, extra arguments passed on (test --filter Matomo) |
live |
vendor/bin/phpunit --group live: Omnireach\Matomo\Tests\LiveTest, two calls to demo.matomo.cloud |
bare |
docker/harness/bin/bare: a registry with the fixed provider, a search report, an audience report with a filter, lastDays(), a recorded JSON API and a refused key; it prints the results and every class and file PHP loaded, as JSON |
phpstan |
PHPStan on the core and each provider with its own phpstan.neon; fails when one fails |
composer ..., php ..., sh |
as they are, in /harness |
$ docker compose run --rm omnireach test
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.26
Configuration: /harness/phpunit.xml
......................................... 41 / 41 (100%)
Time: 00:00.890, Memory: 30.00 MB
OK (41 tests, 157 assertions)
(2026-10-26, every provider from its checkout)
bare prints, among the rest (2026-10-26):
"searchTotals": {
"clicks": 15,
"impressions": 750
},
...
"refused": "Example refused the credentials (401) for it: Invalid API key",
and its loaded.symbols hold, besides PHP's own classes, only Omnireach\... and
Symfony\Contracts\HttpClient\HttpClientInterface and ResponseInterface: no framework class.
Running a script of your own
Mount a directory and run PHP with the harness's autoloader (/harness/vendor/autoload.php):
docker compose run --rm -v "$PWD/scratch:/scratch" omnireach php /scratch/report.php
The examples of this manual were run that way. A provider with an account is called the same way,
its keys in docker/.env (git-ignored) or given with -e NAME=value.
Propose a change docs/harness.md
Writing a provider
A provider is a package omnireach/<name>, namespace Omnireach\<Name>\, with a factory and a
provider class. It requires omnisense/omnireach and symfony/http-client-contracts, nothing else
(symfony/http-client and PHPUnit in require-dev).
The rules
- The factory extends
Omnireach\ProviderFactory:populate()sets its name (omnireach.factory_name, kebab-case:search-console), its title, its required options and the defaults of the others;build()returns the provider. The HTTP client comes through the factory's constructor, never created inside; with none,build()throwsInvalidConfigException. - The provider implements
SearchPerformanceInterfaceorAudienceInterface(or both) and describes itself incapabilities(): its dimensions and metrics, whether dimensions combine, its terms (URLs) and its limits (in words). - A report starts with
$this->capabilities()->check($query, $metrics, '<Title>'),$metricsbeing$query->metricsOr(<the contract's defaults>), so a query it cannot answer costs no call; a filter it cannot apply, even on a dimension it reports, is refused withNotSupportedExceptiontoo, never dropped. It ends with$query->apply($rows), which puts the filters and the limit on what the tool returned. - Units are the family's: ratios from 0 to 1 (a bounce rate in percent is divided by 100),
seconds, dates
Y-m-d, devicesdesktop/mobile/tablet, countries upper case, pages as the tool gives them (full URLs for search engines, paths for analytics). A metric the tool does not give for a row is left out of it, never zero. - Calls go through
Omnireach\Http\JsonApi(every failure becomes the family's exception, with the tool's message); answers are read withSupport\Data, so an unexpected shape gives empty values, never a\TypeError. Keys go in a header or a body when the tool allows it, not in the URL, where logs keep them (Bing's API takes its key only in the query string;JsonApikeeps the query out of its messages: Errors). - Paging: a tool that pages its answers is called page after page until
limitrows are read or a page comes back short. - The note:
Report::$notesays what the figures are not (provisional, sampled, weekly, summed by day), when the provider knows it.
A skeleton
<?php
// ExampleFactory.php and ExampleProvider.php, here in one listing
namespace Omnireach\Example;
use Omnireach\AudienceInterface;
use Omnireach\Capabilities;
use Omnireach\Config;
use Omnireach\Exception\InvalidConfigException;
use Omnireach\Http\JsonApi;
use Omnireach\Model\Dimension;
use Omnireach\Model\Metric;
use Omnireach\Model\ReachQuery;
use Omnireach\Model\Report;
use Omnireach\Model\Row;
use Omnireach\ProviderFactory;
use Omnireach\ProviderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ExampleFactory extends ProviderFactory
{
public function __construct(private readonly ?HttpClientInterface $http = null)
{
}
protected function populate(Config $c): void
{
$c->defaults([
'omnireach.factory_name' => 'example',
'omnireach.factory_title' => 'Example Analytics',
'omnireach.required_options' => ['api_key', 'site'],
'url' => 'https://api.example-analytics.test',
]);
}
protected function build(Config $c): ProviderInterface
{
return new ExampleProvider(
new JsonApi($this->http ?? throw new InvalidConfigException('The "example" provider needs an HTTP client: give one to ExampleFactory.'), 'Example Analytics'),
(string) $c->string('api_key'),
(string) $c->string('site'),
rtrim((string) $c->string('url'), '/'),
);
}
}
final class ExampleProvider implements AudienceInterface
{
public function __construct(private readonly JsonApi $api, #[\SensitiveParameter] private readonly string $apiKey, private readonly string $site, private readonly string $url)
{
}
public function getName(): string
{
return 'example';
}
public function getTitle(): string
{
return 'Example Analytics';
}
public function capabilities(): Capabilities
{
return new Capabilities(
audience: true,
dimensions: [Dimension::EntryPage, Dimension::Date],
metrics: [Metric::Visits, Metric::BounceRate],
combined: false,
terms: ['https://example-analytics.test/terms'],
limits: ['100 requests a minute'],
);
}
public function audience(ReachQuery $query): Report
{
$metrics = $query->metricsOr([Metric::Visits]);
$this->capabilities()->check($query, $metrics, 'Example Analytics');
$site = $query->site ?? $this->site;
$answer = $this->api->request('GET', $this->url.'/stats', [
'query' => ['site' => $site, 'from' => $query->start->format('Y-m-d'), 'to' => $query->end->format('Y-m-d'), 'by' => $query->breakdown()[0]->value ?? 'none'],
'auth_bearer' => $this->apiKey,
], 'the stats of '.$site);
$rows = [];
foreach ($answer->list('rows') as $line) {
$values = [];
foreach ($metrics as $metric) {
$value = match ($metric) {
Metric::Visits => $line->int('visits'),
Metric::BounceRate => null !== ($percent = $line->float('bounce_percent')) ? round($percent / 100, 4) : null, // percent -> ratio
default => null,
};
if (null !== $value) {
$values[$metric->value] = $value; // a metric not given is left out, never 0
}
}
$rows[] = new Row($query->has(Dimension::EntryPage) ? ['entry_page' => (string) $line->string('page')] : [], $values);
}
return new Report('example', $site, $query, $query->apply($rows));
}
}
Run on recorded answers (2026-10-26, in the harness):
$http = RecordedHttpClient::routes(['GET /stats' => '{"rows":[{"page":"/","visits":260,"bounce_percent":41.5},{"page":"/ateliers","visits":110}]}']);
$registry = new Registry([new ExampleFactory($http)], ['stats' => ['factory' => 'example', 'options' => ['api_key' => 'k', 'site' => 'example.org']]]);
$report = $registry->audience('stats')->audience(ReachQuery::lastDays(7, [Dimension::EntryPage], [Metric::Visits, Metric::BounceRate], new \DateTimeImmutable('2026-10-26')));
echo json_encode(array_map(static fn ($r) => $r->toArray(), $report->rows)), "\n";
echo json_encode($http->requests[0]['options']['query']), "\n";
[{"dimensions":{"entry_page":"\/"},"metrics":{"visits":260,"bounce_rate":0.415}},{"dimensions":{"entry_page":"\/ateliers"},"metrics":{"visits":110}}]
{"site":"example.org","from":"2026-10-19","to":"2026-10-25","by":"entry_page"}
The second row has no bounce_rate: the answer gave none.
Its tests
- The provider's own test (
Tests/<Name>ProviderTest.php), on recorded answers - from the tool when a public account or demo exists (Matomo's are), else composed in the shape the tool's reference documents, and said so in the test's docblock: the request sent (URL, body, key), the mapping into rows in the family's units, the refusals (NotSupportedExceptionfor each dimension, filter or combination it cannot answer), a refused key (InvalidKeyException), the factory through aRegistry. Tests/BareTest.php: every class of the package loaded alone, nothing foreign:
use Omnireach\Testing\LoadCheck;
$autoload = LoadCheck::autoload(ExampleProvider::class, self::class, \dirname(__DIR__)) ?? self::markTestSkipped('installed in an application here');
$report = LoadCheck::run(\dirname(__DIR__), 'Omnireach\\Example\\', $autoload);
self::assertContains(ExampleProvider::class, $report['loaded']);
self::assertSame([], $report['foreign']);
self::assertSame([], LoadCheck::outside($report['files'], \dirname(__DIR__), ['/vendor/omnisense/omnireach/', '/omnireach/core/', '/vendor/symfony/http-client-contracts/', '/vendor/symfony/service-contracts/']));
Tests/LiveTest.php(#[Group('live')]), only for what needs no account, never in the default run:docker compose run --rm omnireach live.
The provider joins the harness with a line in docker/harness/plugins.php (its slug and its tests'
namespace), and its README states what was verified and what was not, and the tool's terms
("Responsibility").
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 in the harness, every package from its checkout, PHP 8.4, PHPUnit 11.5: 41 tests, 157 assertions, green.
- The core (
Tests/ModelTest.php,Tests/RegistryTest.php): the query (whole days, its validation, filters then limit,lastDays()ending yesterday), the capabilities refusing what a provider cannot report, reports and their totals, the registry and its typed getters,JsonApi's errors. - In plain PHP (
Tests/BareTest.php): reports asked through aRegistryand a recorded JSON API, loading nothing but Omnireach and Symfony's HTTP client contracts. - Each provider: its requests and its mapping on answers recorded or composed (below), its
refusals, a refused key, its factory through a
Registry, and aBareTestloading every class with the package's requirements alone. - PHPStan at the highest level on the core and the five providers, no error baselined
(
docker compose run --rm omnireach phpstan, 2026-10-26).
| Provider | Answers its tests use |
|---|---|
| search-console | composed in the shape the Search Console reference documents; the service account's JWT checked with a key made by the test |
| bing-webmaster | the JSON sample of GetQueryStats in Bing's reference, and rows composed in its shape |
| matomo | recorded from Matomo's public demo (demo.matomo.cloud, site 1) on 2026-10-24, trimmed of unread fields |
| plausible | composed in the shape the Stats API v2 documents |
| ga4 | composed in the shape the runReport reference documents; a service account's token with a key made by the test |
There is no conformance suite shared by the providers (Testing).
What was run live
docker compose run --rm omnireach live(Omnireach\Matomo\Tests\LiveTest), 2026-10-24: Matomo's public demo, the whole site over a week and its entry pages day by day.- For this manual, 2026-10-26, 14:49-14:51 UTC: the Installation
script against the same demo, and a report by device and with device filters
(Audience), which showed two defects of Matomo's devices, fixed on
2026-10-29 (three
mobilerows, amobilefilter refused). Every segment the demo was asked for (deviceType==smartphone,deviceType==desktop) answered 0 visits: the demo does not process segments on the fly, so filters on Matomo were not verified live.
What is not verified
- Search Console, Bing Webmaster, Plausible and Google Analytics were never called: each needs an account and a key. Their answers in the tests follow their published references.
- A self-hosted Matomo with a token.
- Positions as Bing gives them (
AvgImpressionPosition), and how Search Console and Plausible match a device filter's case (mobileis sent as given). - The Symfony and Laravel pages were written from the classes' constructors, not run in a kernel or an application.
Known defects
None left: those the manuals found were fixed on 2026-10-29.
Responsibility
omnisense/omnireach and its omnireach/* providers are unofficial clients, affiliated with none
of the services they reach. The MIT licence covers the code only and grants no right to any
service or to its data. Whoever runs them complies with each service's terms (named in each
provider's README and manual, and in Capabilities::$terms) and obtains what they require.