Skip to content

omnisense/omnireach

Omnireach: what outside tools know of a site's audience, under any PHP host - search queries, pages and positions from the search engines (Search Console, Bing Webmaster Tools), visitors, entry pages and sources from the analytics tools (Matomo, Plausible, Google Analytics 4), one query and one report whoever answers; a taken-over site's history over any period. Providers are packages of their own (omnireach/search-console, omnireach/bing-webmaster, omnireach/matomo, omnireach/plausible, omnireach/ga4); the Symfony and Laravel bridges live in omnibridge.

Floor
Family
Version
1.x
Verified for real
Verified in part
Licence
MIT
composer require omnisense/omnireach:1.x-dev

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

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/omnireach requires symfony/http-client-contracts (^3.0) and nothing else.
  • Every provider requires the core and symfony/http-client-contracts only: the HTTP client itself is the application's. Any implementation of Symfony\Contracts\HttpClient\HttpClientInterface does - symfony/http-client (a library that stands alone and loads no framework) with HttpClient::create(), or a MockHttpClient in 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-console and omnireach/ga4 require ext-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 typed search() and audience(), 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 Registry once, 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:

  1. createConfig($options) - a Config (an ArrayObject) holding the options given, then the factory's populate() fills each key the options did not set with its default;
  2. the keys listed under omnireach.required_options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key (The "bing-webmaster" provider needs: api_key, site.);
  3. 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

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 date outside 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 provider is fixed, its site the query's, else the site option, else https://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_PLUGINS when one is there - set it to ../.. in docker/.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/phpunit 11, phpstan/phpstan 2.1 and symfony/http-client for 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() throws InvalidConfigException.
  • The provider implements SearchPerformanceInterface or AudienceInterface (or both) and describes itself in capabilities(): 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>'), $metrics being $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 with NotSupportedException too, 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, devices desktop/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 with Support\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; JsonApi keeps the query out of its messages: Errors).
  • Paging: a tool that pages its answers is called page after page until limit rows are read or a page comes back short.
  • The note: Report::$note says 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 (NotSupportedException for each dimension, filter or combination it cannot answer), a refused key (InvalidKeyException), the factory through a Registry.
  • 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 a Registry and 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 a BareTest loading 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 mobile rows, a mobile filter 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 (mobile is 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.

Propose a change docs/verified.md