Aller au contenu

omnisense/omnimap

Omnimap: maps under any PHP host, whoever draws them - one description of a view (centre, zoom, markers) that a provider's driver draws in the browser, the provider's image of it made on the server, geocoding over geocoder-php, and the map cache: the picture of a view, signed, cut in tiles, kept in a storage of the host's and served from it, captured by the browser or made by the server. Providers are packages of their own (omnimap/google, omnimap/openstreetmap); the Symfony and Laravel bridges live in omnibridge.

Étage
Famille
Version
1.x
Suite de conformité
branchée, CI verte
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omnimap:1.x-dev

Omnimap is one PHP contract for web maps, whoever draws them. A page describes a view once - its centre, its zoom, its markers - and the provider configured for it turns that view into what is needed: a description the browser draws with the provider's own library (Google's Maps JavaScript API, Leaflet, MapLibre GL JS), an image of the view made on the server, the tiles of a slippy map, places found from an address and addresses found from a point. Each provider is a package of its own (omnimap/google, omnimap/openstreetmap, omnimap/maplibre) that answers the contracts it can and says which in its Capabilities.

$registry->renderer('maps')->describe($view);            // array: what the browser's driver draws
$registry->staticMap('maps')->image($view, new Size(640, 400));   // Image: PNG bytes made on the server
$registry->geocoder('maps')->geocode('Kyoto');           // Place[], the best first
$registry->tiles('maps')->url(12, 3592, 1622);           // one tile's URL
$registry->get('maps')->capabilities();                  // what it does, which hosts the browser reaches

Around the contracts sits the map cache: the picture of a view, named by a signature of what it depends on, cut in square tiles and kept in the host's storage (a directory, a Flysystem storage, an omnidrive drive). A page then shows the picture instead of the live map, and a visitor's browser reaches no third party at all. The picture comes from the browser (an administrator's capture of the live map) or from the server (a provider's static image, or tiles put together): the cache is the same for every provider. This is the cache glitchr/ux-google kept for lesringostars, made independent of Google; its file layout and its names are kept, so pictures captured before omnimap are still served.

The core needs no framework: it requires psr/simple-cache, symfony/http-client-contracts (interfaces only) and willdurand/geocoder (geocoder-php's interfaces and models). Geocoding is geocoder-php's: omnimap does not parse a geocoding API itself, it puts a geocoder-php provider behind its own contract with a cache and a pace. The Symfony and Laravel wiring lives in omnibridge (Symfony, Laravel). The browser side is three ES modules without a library (The browser).

When to use it

  • A site that shows maps and wants to be able to change provider (Google to OpenStreetMap, raster to vector tiles) without touching its pages: the view, the markup and the cache stay the same.
  • A site that must not send its visitors to a third party without their consent: every live map goes through a consent gate, and a cached picture reaches no one.
  • An application that needs geocoding with the caching and the pace the services' terms ask for (Nominatim: one request a second at most, results cached).
  • A server that makes map images (an e-mail, a PDF, an Open Graph image) from a tile server.

It is not a map library: drawing, panning and zooming are the provider's library's job in the browser. It does not store anything but the pictures of the map cache, and it does not decide whether a provider's terms allow a picture of its map to be kept: whoever runs it answers for that (see Responsibility).

Providers

Three packages. The capabilities are what each provider's capabilities()->contracts() answers, probed in the harness on 2026-10-25 with the options and clients named; "with X" means the capability is off until X is given. The browser hosts are the defaults.

Package Factory name Renderer (driver) Static map Geocoder Tiles Browser hosts
omnimap/google google yes (assets/google.js, Maps JavaScript API) with an HTTP client and a key (server_key or api_key) with a PSR-18 client and a key (geocoder-php's GoogleMaps) - maps.googleapis.com, maps.gstatic.com, fonts.googleapis.com
omnimap/openstreetmap openstreetmap yes (assets/leaflet.js, Leaflet 1.9.4) with an HTTP client and a user_agent, tiles put together with a PSR-18 client, a user_agent and a nominatim URL (geocoder-php's Nominatim) yes tile.openstreetmap.org, unpkg.com
omnimap/maplibre maplibre yes (assets/maplibre.js, MapLibre GL JS 5.24.0) - - - tiles.openfreemap.org, unpkg.com

The core ships a fourth provider for tests, Omnimap\Testing\TestTiles (name test: renderer, static map, tiles, on https://tiles.example/), with no factory (Testing).

Each provider's own manual lists its options, its description's keys and its behaviour. A new provider starts from glitchr-omnisense/omni-provider-template (Writing a provider).

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omnimap omnimap/openstreetmap symfony/http-client

Requirements

  • PHP 8.2 or later.
  • omnisense/omnimap requires psr/simple-cache (^2.0 or ^3.0), symfony/http-client-contracts (^3.0, interfaces only) and willdurand/geocoder (^5.0, geocoder-php's interfaces and models), and nothing else: no framework, no HTTP client.
  • ext-gd for the map cache and for images made of tiles: SnapshotStore::save() cuts a picture with GD, Static\TileStitcher puts tiles together with it (PNG, JPEG and WebP support for the formats you keep). Without GD, live maps, descriptions, geocoding and tile URLs still work.
  • A provider that calls a service needs an HTTP client: symfony/http-client (a library that stands alone) for tiles and Static Maps; for geocoding, a PSR-18 client - Symfony's Psr18Client over the same client works, with a PSR-17 factory such as nyholm/psr7 installed.
  • Suggested by the core, each for one storage of the map cache: league/flysystem (Snapshot\FlysystemStorage) and omnisense/omnidrive (Snapshot\DriveStorage).

The providers require the core, symfony/http-client-contracts and, when they geocode, psr/http-client, psr/simple-cache and their geocoder-php provider (geocoder-php/google-maps-provider ^4.7, geocoder-php/nominatim-provider ^5.7). omnimap/maplibre requires the core and symfony/http-client-contracts only.

Plain PHP

GD (ext-gd) cuts and makes the pictures. Everything below runs without a network: the tiles come from Omnimap\Testing\TestTiles, a provider for tests whose tiles a recorded HTTP client draws. Put omnimap/openstreetmap's provider in its place, with your application's User-Agent, and the same code fetches real tiles. Saved as map.php:

<?php

use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\MapHtml;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\FixedGeocoder;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;

require 'vendor/autoload.php';

$provider = new TestTiles(new RecordedHttpClient(PlainTiles::answer()), new ArrayCache());
$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')], controls: false, id: 'map');

// The map cache: pictures named by a signature, kept in a directory, made by the server from tiles.
$dir = sys_get_temp_dir().'/omnimap-docs-'.getmypid();
$snapshots = new Snapshots($provider, new Signer('c2VjcmV0'), new SnapshotStore(new DirectoryStorage($dir), 'maps'), $provider);
$snapshot = $snapshots->findOrMake($view);
echo "{$snapshot->width}x{$snapshot->height} in {$snapshot->xtiles}x{$snapshot->ytiles} tiles of {$snapshot->tilesize}\n";
// 1512x938 in 3x2 tiles of 512

// The page's element: the picture's frame, which assets/snapshot.js fills with the tiles.
$html = (new MapHtml(['src' => '/maps/{signature}/{id}.webp']))->snapshot('map', $snapshot->signature, $snapshot, $provider->attribution());
echo substr_count($html, 'data-omnimap="snapshot"'), " frame\n";
// 1 frame

// Geocoding: a geocoder-php provider behind omnimap's contract, its answers kept.
$geocoder = new GeocoderPhp(new FixedGeocoder(['Kyoto' => ['lat' => 35.0116, 'lng' => 135.7681, 'locality' => 'Kyoto', 'country' => 'Japan']]), new ArrayCache());
echo $geocoder->geocode('Kyoto')[0]->label, "\n";
// Kyoto, Japan

$snapshots->delete($snapshot->signature);
@rmdir("$dir/maps");
@rmdir($dir);
$ php map.php
1512x938 in 3x2 tiles of 512
1 frame
Kyoto, Japan

(run on 2026-10-25 in the harness, PHP 8.4 with GD; Tests/DocsTest.php runs the block above as it is written and checks these three lines, on every run of the suite)

No key, no network. The pieces:

  • a provider: here TestTiles, which describes a view, gives tile URLs and makes images of tiles; in an application, a factory's provider (OpenStreetMapFactory, GoogleFactory, MapLibreFactory), usually through a Registry;
  • the map cache: Snapshots names a view by a signature (Signer, with the host's secret), finds its picture in a SnapshotStore or makes it with the provider's StaticMapInterface, and the store cuts it into 512-pixel tiles in a Storage (DirectoryStorage here);
  • MapHtml: the page's markup, a picture's frame (snapshot()) or a live map (live()), which the browser's modules fill (The browser);
  • GeocoderPhp: a geocoder-php provider (FixedGeocoder here, a test double) behind omnimap's models, its answers kept in a PSR-16 cache (ArrayCache here).

No class of a framework, of an HTTP client or of Flysystem is loaded on the way: Tests/BareTest.php checks it in a process of its own, with docker/harness/bin/bare.

With a real provider

The same view drawn live by Leaflet over OpenStreetMap's tiles, and its picture made by the server from those tiles. Fetching tiles needs a user_agent naming the application and a contact (the OpenStreetMap Foundation's tile policy blocks a library's default one); HttpClient::create() is any Symfony\Contracts\HttpClient\HttpClientInterface.

<?php

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

use Omnimap\MapHtml;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

$http = HttpClient::create();
$registry = new Registry([new OpenStreetMapFactory($http, new Psr18Client($http))], [
    'maps' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)']],
]);
$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')], id: 'map');

// Live: the description omnimap.js and leaflet.js draw, behind the page's consent gate.
echo (new MapHtml())->live('map', $registry->renderer('maps')->describe($view));

// Cached: the picture made once by the server (35 tiles for 1512 x 938), then read from /var/maps.
$snapshots = new Snapshots(
    $registry->renderer('maps'),
    new Signer(getenv('MAP_SECRET') ?: ''),
    new SnapshotStore(new DirectoryStorage('/var/maps')),
    $registry->staticMap('maps'),
);
echo (new MapHtml())->snapshot('map', $snapshots->signature($view), $snapshots->findOrMake($view), $registry->renderer('maps')->attribution());

// Geocoding: Nominatim, one call a second at most in this process.
echo $registry->geocoder('maps')->geocode('Kyoto, Japan', 'en', 1)[0]->label;

This script reaches tile.openstreetmap.org and nominatim.openstreetmap.org: it was not run for this page. The harness's live command runs the same calls (an image of Kyoto from 35 tiles and one Nominatim query), last on 2026-10-23 (Verified, and not). In production, the Foundation's tile server is for modest use only: its policy asks heavy users for a tile provider of their own or their own server (the tiles option, see omnimap/openstreetmap's manual). The page then needs assets/omnimap.js and assets/leaflet.js (The browser) and, for the cached picture, assets/snapshot.js and a route that serves the tiles (The map cache).

In a framework

  • Symfony: omnibridge/symfony's OmnimapExtension builds the registry, the cache and the printer from config/packages/omnimap.yaml, routes the cache and gives Twig omnimap(): Symfony.
  • Laravel: omnibridge/laravel's Map\MapServiceProvider reads config/omnimap.php, the same keys: Laravel.
  • Any other: build the Registry (and Snapshots, MapPrinter) once, where the framework builds its services, as the scripts above do.

Proposer une modification docs/installation.md

Symfony

The core holds nothing of a framework. In a Symfony application, omnibridge/symfony wires it: Omnibridge\Symfony\Map\DependencyInjection\OmnimapExtension, registered by Omnibridge\Symfony\OmnibridgeBundle when omnisense/omnimap is installed, builds the providers, the registry, the map cache and the printer from config/packages/omnimap.yaml; omnibridge/twig's Omnibridge\Twig\MapExtension gives Twig omnimap(); a route loader and a controller serve the cache. Without an omnimap.yaml, the extension loads nothing.

composer require omnibridge/symfony omnisense/omnimap omnimap/openstreetmap

with Omnibridge\Symfony\OmnibridgeBundle in config/bundles.php. The Twig functions need omnibridge/twig and TwigBundle; cache.drive needs omnisense/omnidrive and the bridge's Omnidrive\Drives service; geocoding needs a PSR-17 factory (nyholm/psr7 or php-http/discovery) for the PSR-18 client.

omnimap.yaml

# config/packages/omnimap.yaml
omnimap:
    provider: openstreetmap            # the page's map: a key of providers
    secret: '%env(MAP_SECRET)%'        # signs the pictures' names
    providers:
        openstreetmap: { user_agent: 'MySite/1.0 (+https://example.org)' }
        google: { api_key: '%env(GOOGLE_MAPS_CLIENT_KEY)%', secret: '%env(GOOGLE_MAPS_SECRET)%' }
    cache:
        drive: local.uploads           # an omnidrive drive (Omnidrive\Drives); or storage: an Omnimap\Snapshot\Storage service
        public_directory: '%kernel.project_dir%/public'   # tiles served are written there too
    routes:
        tile: { path: '/omnimap/{signature}/{id}.webp', name: omnimap_tile }
# config/routes/omnimap.yaml
omnimap: { resource: ., type: omnimap }

The whole tree, from Omnibridge\Symfony\Map\DependencyInjection\Configuration:

Key Type Default
provider string required, not empty the provider of the page's map: a key of providers
geocoder string or null null the provider Omnimap\GeocoderInterface is (a key of providers); the page's provider when null
secret string '' signs the pictures' names (URL-safe base64; Google's URL signing secret fits)
providers map of name => options {} each provider's options, as its factory reads them (Configuration); a factory key names the factory when the name is not one (places: { factory: google, ... })
cache_only bool true a visitor never gets the live map: the picture kept, or an empty frame
control string ROLE_ADMIN the attribute (Access::isGranted()) of whoever may capture and delete pictures, and sees the live map while there is none
cache.drive string or null null the omnidrive drive the cache is kept on (Omnidrive\Drives: a storage's name, local.uploads)
cache.storage string or null null a service implementing Omnimap\Snapshot\Storage, when the cache is on no omnidrive drive; drive or storage is required
cache.directory string omnimap the folder of the pictures in the storage
cache.pattern string {signature}/image-{id} a tile's path in it
cache.format png, jpeg, jpg, webp png the tiles' format as kept
cache.tilesize int 512 the tiles' size; 0 or less keeps pictures whole
cache.make auto, true, false auto whether the server makes a missing picture itself: when the page's provider makes images (auto), always (true: a provider that does not implement StaticMapInterface is a NotSupportedException when the cache is built), never (false: a host that reads another host's cache)
cache.size string 1512x938 the size of a picture the server makes (WIDTHxHEIGHT)
cache.scale int, 1 or 2 1 its density
cache.pool string cache.app the PSR-6 pool, seen as PSR-16 (omnimap.cache), that keeps fetched tiles and geocoders' answers
cache.max_age int 86400 the HTTP lifetime of a tile served by the route
cache.public_directory string or null null where a tile served is also written, at the route's own path (the web server then serves it without PHP): the public folder
cache.public_size int or null null the largest side of a tile served as WebP, in pixels (none: as kept)
html.class string omnimap-snapshot the frame's class (The markup)
html.suffix string _snapshot the frame's id suffix
html.missing string '' the frame's data-missing: the address of the picture snapshot.js shows for a missing picture or tile
routes.tile {path, name} /omnimap/{signature}/{id}.webp, omnimap_tile a tile (also the src the frame carries)
routes.metadata {path, name} /omnimap/{signature}, omnimap_metadata a picture's metadata
routes.export {path, name} /omnimap/{signature}/export, omnimap_export the capture's post
routes.suppress {path, name} /omnimap/{signature}/suppress, omnimap_suppress the deletion's post

lesringostars keeps glitchr/ux-google's names: cache.directory: google, html: { class: google-tilemap, suffix: _html2canvas }, routes.tile: { path: '/google/{signature}/{id}.webp' }.

Services

Service id Class
Omnimap\Google\GoogleFactory, Omnimap\OpenStreetMap\OpenStreetMapFactory, Omnimap\MapLibre\MapLibreFactory the factories registered when their package is installed, Google's and OpenStreetMap's over http_client, omnimap.psr18 and omnimap.cache, MapLibre's over http_client, tagged omnimap.provider_factory
Omnimap\Registry (public) Registry every service tagged omnimap.provider_factory (the factories above, an application's own by the tag), and providers
Omnimap\MapRendererInterface the page's provider Registry::renderer(provider)
Omnimap\GeocoderInterface the geocoder Registry::geocoder(geocoder ?? provider)
Omnimap\Snapshot\SnapshotStore the store on cache.storage, or a DriveStorage of Drives::get(cache.drive)
Omnimap\Snapshot\Snapshots (public) the cache the page's renderer, Signer(secret), the store, the static map by cache.make, cache.size, cache.scale, cache.tilesize
Omnimap\MapHtml the markup html and the tile route's path as src
Omnimap\MapPrinter (public) the page's rule cache_only, errors logged as warnings on logger (omnimap: the map's picture could not be made: ...)
Omnibridge\Twig\MapExtension Twig when omnibridge/twig and TwigBundle are there, tagged twig.extension
Omnibridge\Symfony\Map\SnapshotController (public) the routes' controller
Omnibridge\Symfony\Map\MapRouteLoader route loader of type omnimap
omnimap.cache Psr16Cache over cache.pool
omnimap.psr18 Psr18Client over http_client when a PSR-17 factory is installed; otherwise no provider geocodes

All are autowirable by their class: public function __construct(private readonly Registry $maps, private readonly GeocoderInterface $geocoder) {}.

Twig functions

Function
omnimap(view) MapPrinter::map(view, mayChange): the picture kept (made now when the server can), else the empty frame for a visitor (cache_only), else the live map; '' for a null view
omnimap_export(view, {text: 'O', class: ...}) for whoever has control: the button that sends the live map's picture to routes.export, with a token of intent omnimap-export; '' for anyone else
omnimap_suppress(view, {text: 'X', ...}) for whoever has control, when a picture is kept: the button that deletes it (routes.suppress, intent omnimap-suppress)
omnimap_signature(view) the view's signature

The buttons' text (default O and X) is printed as it is; every other key is an attribute of the button. The view is a MapView the controller passes to the template:

return $this->render('store/show.html.twig', [
    'map' => new MapView(new LatLng($store->lat, $store->lng), 15, [new Marker(new LatLng($store->lat, $store->lng), $store->name)], id: 'store-map'),
]);
<div style="position: relative; height: 400px">{{ omnimap(map) }}</div>
{{ omnimap_export(map, {text: 'Keep this picture', class: 'btn'}) }}
{{ omnimap_suppress(map, {text: 'Delete the picture', class: 'btn'}) }}

Routes and controller

MapRouteLoader (type omnimap) gives four routes, the signature matching [A-Za-z0-9_-]{1,128}, the tile first (its path may lie under the metadata's):

Route Method Controller
routes.tile GET, HEAD SnapshotController::tile id digits, default 0
routes.export POST SnapshotController::export
routes.suppress POST SnapshotController::suppress
routes.metadata GET, HEAD SnapshotController::metadata
  • tile: the tile kept, 404 when there is none (or the signature is not one). When the route's path ends in .webp and the store's format is not WebP, the tile is converted: with GD built with WebP (fitted within public_size, quality 75), else Imagick with WebP, else served as kept. With a public_directory, it is also written there at the route's own path. Public, max-age=max_age, must-revalidate, an ETag (MD5 of the bytes), 304 when it matches.
  • metadata: the picture's metadata.txt as JSON, {"status": "BAD"} when there is none.
  • export: for whoever Access::isGranted(control), with a valid token of intent omnimap-export (when a Tokens service exists): Snapshots::save(signature, image, tilesize) from the posted image (a data: URL) and tilesize (else cache.tilesize), answered with the picture's metadata; then the tiles written in the public directory for that signature are deleted. No image: 400; not an image: 400; no right or a wrong token: 403.
  • suppress: the same right, intent omnimap-suppress: the picture deleted, its public tiles too, {"status": "OK"}.

The Access, Tokens and Urls services are omnibridge's host contracts (Omnibridge\Contract\*), which omnibridge/symfony implements over the security component, the CSRF token manager and the router. Without an Access service, nobody may change the map.

Testing a Symfony application

The MapTest of omnibridge/symfony is a model: replace http_client in the test environment with a MockHttpClient answering the tiles (Omnimap\Testing\PlainTiles::png()), point cache.drive or cache.storage at a temporary directory, and request the page, the tiles and the routes (Testing).

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel wires omnimap into a Laravel application: Omnibridge\Laravel\Map\MapServiceProvider, registered by Omnibridge\Laravel\OmnibridgeServiceProvider when omnisense/omnimap is installed, reads config/omnimap.php - the keys of omnibridge/symfony's omnimap.yaml, fewer of them - and binds the registry, the map cache, the printer and omnibridge/twig's MapExtension, and routes the tiles. It routes nothing and gives Twig nothing unless config/omnimap.php names a provider.

composer require omnibridge/laravel omnisense/omnimap omnimap/openstreetmap

config/omnimap.php

// config/omnimap.php
return [
    'provider' => 'openstreetmap',
    'secret' => env('MAP_SECRET'),
    'providers' => [
        'openstreetmap' => ['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)'],
        'places' => ['factory' => 'google', 'server_key' => env('GOOGLE_MAPS_SERVER_KEY')],
    ],
    'cache' => ['storage' => storage_path('app/omnimap'), 'directory' => 'omnimap', 'make' => 'auto'],
    'routes' => ['tile' => ['path' => '/omnimap/{signature}/{id}.webp', 'name' => 'omnimap_tile', 'register' => true]],
];

Every key, as MapServiceProvider::config() and the bindings read them:

Key Default
provider '' the page's provider, a key of providers; empty: no route, no Twig function
secret '' signs the pictures' names
providers [] name => the factory's options; a factory key names the factory when the name is not one
geocoder the page's provider the provider bound as Omnimap\GeocoderInterface
cache.drive - an omnidrive drive of the bound Omnidrive\Drives (a storage's name: local.uploads)
cache.storage storage_path('app/omnimap') when there is no drive: the container's name of an Omnimap\Snapshot\Storage, else a directory (DirectoryStorage)
cache.directory omnimap
cache.pattern {signature}/image-{id}
cache.format png png, jpeg, jpg, webp
cache.tilesize 512 0 or less keeps pictures whole
cache.make auto auto: the server makes a missing picture when the page's provider declares staticMap; true or 'true': whenever it implements StaticMapInterface; false or 'false': never
cache.size 1512x938
cache.scale 1
cache.pool - a store of Laravel's cache (config/cache.php): the providers' PSR-16 cache (tiles, geocoders' answers); without it a Symfony FilesystemAdapter (namespace omnimap) in storage/framework/cache/omnimap
cache.max_age 86400 a tile's max-age
cache.public_directory - where each tile served is written at the route's path, for the web server to serve next
cache.public_size - a tile served as WebP is fitted within it
cache_only true anything but false keeps visitors off the live map
control ROLE_ADMIN the ability (Gate) of whoever may change the map
html.class, html.suffix, html.missing omnimap-snapshot, _snapshot, '' the frame's markup (The markup); src is the tile route's path
routes.tile /omnimap/{signature}/{id}.webp, omnimap_tile, register: true the tile route; register: false for a host whose web server serves the tiles, or that reads another host's cache
routes.metadata, routes.export, routes.suppress /omnimap/{signature}, .../export, .../suppress; omnimap_metadata, omnimap_export, omnimap_suppress names only: these routes are not registered (below)

These are omnibridge/laravel 1.x's since 77de4a4; before it geocoder and the four last cache keys were read by nothing.

Bindings

Binding
Omnimap\Registry (singleton) GoogleFactory, OpenStreetMapFactory and MapLibreFactory when installed (MapServiceProvider::FACTORIES), and every binding tagged omnimap.provider_factory (MapServiceProvider::FACTORY_TAG), each over the HTTP client bound as omnimap.http_client (else HttpClient::create()), a Psr18Client over it when nyholm/psr7 or php-http/discovery is installed (else none: no provider geocodes), and the cache above
Omnimap\Snapshot\Snapshots (singleton) the page's renderer, Signer(secret), a SnapshotStore on the storage above, the static map by cache.make
Omnimap\MapRendererInterface the page's provider (Snapshots::renderer())
Omnimap\MapPrinter (singleton) cache_only; a picture the server could not make logged as a warning when a LoggerInterface is bound
Omnibridge\Twig\MapExtension (singleton) the printer, the host's Urls, Access and Tokens (omnibridge/laravel's: the router, the Gate, the session), control and the export and suppress route names

Omnimap\GeocoderInterface is bound to the registry's geocoder named by geocoder (the page's provider by default); another one is the registry's, app(Registry::class)->geocoder('places').

Routes

Only the tile route is registered: GET routes.tile.path, the signature matching [A-Za-z0-9_-]{1,128} and the id digits, served by Omnibridge\Laravel\Map\TileController as omnibridge/symfony's controller serves it: at a path ending in .webp the tile as WebP (fitted within cache.public_size; GD with WebP, else Imagick, else as kept, Content-Type: image/<format>, jpg as image/jpeg), written in cache.public_directory when set, public, max-age cache.max_age, must-revalidate, an ETag (MD5 of the bytes), 304 when it matches, 404 when there is none.

The metadata, the capture and the deletion of a picture are routed by omnibridge/symfony only. A Laravel host that shows the buttons routes them itself, under the names in routes.export.name and routes.suppress.name (the buttons' URLs are built from those names, and building them fails when no route has the name). Written by hand, with the cache's own methods:

// routes/web.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Omnibridge\Contract\Tokens;
use Omnibridge\Twig\MapExtension;
use Omnimap\Snapshot\Snapshots;

Route::post('/omnimap/{signature}/export', function (Request $request, Snapshots $snapshots, Tokens $tokens, string $signature) {
    abort_unless(Gate::allows('ROLE_ADMIN') && $tokens->isValid(MapExtension::EXPORT, $request->input('_token')), 403);

    return $snapshots->save($signature, (string) $request->input('image'), (int) $request->input('tilesize') ?: null)->metadata();
})->where('signature', '[A-Za-z0-9_-]{1,128}')->name('omnimap_export');

Route::post('/omnimap/{signature}/suppress', function (Request $request, Snapshots $snapshots, Tokens $tokens, string $signature) {
    abort_unless(Gate::allows('ROLE_ADMIN') && $tokens->isValid(MapExtension::SUPPRESS, $request->input('_token')), 403);
    $snapshots->delete($signature);

    return ['status' => 'OK'];
})->where('signature', '[A-Za-z0-9_-]{1,128}')->name('omnimap_suppress');

(not run; a picture that is not an image throws Omnimap\Exception\InvalidArgumentException, which such a route should turn into a 400). The buttons' token is omnibridge/laravel's SessionTokens::csrf(): Laravel's session token, the one its CSRF middleware checks on the web group, the same for every intent - the posts of capture.js pass that middleware as they are.

In a page

With omnibridge/laravel's Twig views, omnimap(), omnimap_export(), omnimap_suppress() and omnimap_signature() are there as under Symfony (Twig functions). In a Blade view, print the printer's markup:

<div style="position: relative; height: 400px">
    {!! app(\Omnimap\MapPrinter::class)->map($map, Gate::allows('ROLE_ADMIN')) !!}
</div>

The browser side is the same as everywhere (The browser).

Tested

omnibridge/laravel's Tests/MapTest.php (Orchestra Testbench): a page's Twig has omnimap(); the picture is made by the server from OpenStreetMap's tiles (answered by a MockHttpClient bound as omnimap.http_client) and kept on the omnidrive drive local.uploads (a local disk); its tiles are served by the route (a 404 past the grid); a user allowed ROLE_ADMIN by the Gate gets the export button. This page was written from that code and that test, not run in an application.

Proposer une modification docs/laravel.md

Concepts

Providers and capabilities

Omnimap\ProviderInterface is one map provider, configured. Every provider has:

Method Returns
getName(): string the factory's name (google, openstreetmap, maplibre): also the provider of its descriptions and the driver the browser picks
getTitle(): string its display name (Google Maps, OpenStreetMap, MapLibre), used in error messages
capabilities(): Capabilities what it can be asked, and what it costs the visitor's privacy

What a provider can be asked is a set of four contracts, each an interface extending ProviderInterface. A provider implements those it can answer:

Contract Capability Methods
MapRendererInterface renderer: draws a map in the browser describe(MapView): array, fingerprint(MapView): string, attribution(): string
StaticMapInterface staticMap: makes an image of a map on the server image(MapView, Size, int $scale = 1): Image
GeocoderInterface geocoder: places from an address, addresses from a point geocode(string, ?string $locale = null, int $limit = 5): list<Place>, reverse(LatLng, ?string $locale = null, int $limit = 5): list<Place>
TileSourceInterface tiles: square Web Mercator tiles, one URL each template(), url(int, int, int), tileSize(), maxZoom(), attribution(), headers()

Implementing an interface is not enough to be asked: a provider built without the client a capability needs (no HTTP client, no PSR-18 client, no key) still implements the interface but declares the capability off, and its method throws NotSupportedException before any request. The Registry's typed getters check the interface only; capabilities() is what to read beforehand.

Capabilities

Omnimap\Capabilities is a final readonly value, built with named arguments:

Property Type Default
renderer bool false it draws a map in the browser
staticMap bool false it makes an image of a map on the server
geocoder bool false it geocodes
tiles bool false it gives tile URLs
browserHosts list<string> [] the host names (no scheme, no path) a visitor's browser reaches when the map is drawn live: what a consent banner asks about and what a Content-Security-Policy must allow
cookies bool false whether its browser script sets or reads cookies
terms list<string> [] the https:// addresses of the terms whoever runs it must follow

contracts(): list<string> lists the capabilities that are on, by name (renderer, staticMap, geocoder, tiles); toArray() gives every property and contracts, ready for JSON.

Factories and Config

Omnimap\ProviderFactoryInterface builds a provider from options:

Method
getName(): string the name providers are configured with (factory: in a configuration)
create(array $options = []): ProviderInterface a provider, its options checked

Every provider's factory extends Omnimap\ProviderFactory, which implements both methods around two of its own:

  1. createConfig($options) makes a Config (an ArrayObject) holding the options given, then calls the factory's populate(Config $c), which sets its name, its title, its required options and the defaults of the others with $c->defaults([...]) - only the keys the options did not set;
  2. create() checks the keys listed in omnimap.required_options: one missing, '' or [] is an InvalidConfigException naming every missing key (The "acme" provider needs: api_key.);
  3. build(Config $c) returns the provider.

getName() is omnimap.factory_name of an empty configuration (the class name when there is none). Building calls nothing: HTTP clients, PSR-18 clients and caches come through the factory's own constructor, and a factory given none builds a provider with the capabilities that need them off.

The reserved keys:

Key
omnimap.factory_name the provider's name, snake_case or kebab-case (^[a-z0-9][a-z0-9_-]*$)
omnimap.factory_title its display name
omnimap.required_options list<string>: options that must be filled

Config reads options as an environment variable may give them:

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, or $default when the key is not set
string(string $key): ?string a scalar as a string; null when it is not set, not scalar, or ''
int(string $key, int $default = 0): int a numeric value as an integer, else $default
float(string $key, float $default = 0.0): float a numeric value as a float, else $default
bool(string $key): bool true for true and for the strings 1, true, yes, on (any case, trimmed); false otherwise

Registry

Omnimap\Registry holds the application's providers by name, each built once:

$registry = new Registry(
    [new GoogleFactory($http, $psr18, $cache), new OpenStreetMapFactory($http, $psr18, $cache)],   // iterable, keyed by getName()
    [
        'maps'   => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
        'places' => ['factory' => 'google', 'options' => ['server_key' => '...']],   // options default to []
    ],
);
Method
get(string $name): ProviderInterface the provider, built on the first call and kept
has(string $name): bool whether a provider of that name is configured
renderer(string $name): MapRendererInterface the provider, as a renderer
staticMap(string $name): StaticMapInterface the provider, as a static map
geocoder(string $name): GeocoderInterface the provider, as a geocoder
tiles(string $name): TileSourceInterface the provider, as a tile source
options(string $name): array the options it is configured with ([] for an unknown name)
names(): list<string> the configured names, in the configuration's order
factories(): list<string> the factories installed: what factory may name

An unknown name (No "x" map provider; configured: maps, places.) and a factory that is not installed (No "google" factory for the "places" map provider; installed: openstreetmap.) are InvalidConfigExceptions. A typed getter on a provider that does not implement the interface is a NotSupportedException (The "vector" provider (Omnimap\MapLibre\MapLibreProvider) does not make a map image.); it does not look at capabilities().

The contracts

MapRendererInterface

The map in the browser. describe(MapView $view): array returns plain, JSON-ready data that the page's script draws with the provider's driver: no code, the same for a Twig page, a Laravel view or a React component. Every description holds the envelope schema/map-description.schema.json defines - provider, consent, view, attribution, and optionally library and options - with the provider's own keys beside it (A live map).

fingerprint(MapView $view): string is the text everything a picture of the view depends on is written in: two views with the same fingerprint must look the same. The map cache names a picture by the fingerprint, signed (The map cache). Each provider puts in it its name, the view's centre, zoom and markers, and what else changes its drawing (the controls, the tile URL, the style, the view's options of its own).

attribution(): string is the attribution the map must show, as HTML: printed beside a kept picture. It may be empty when the live map shows its own (Google).

StaticMapInterface

image(MapView $view, Size $size, int $scale = 1): Image - a map image made on the server: a provider's static map service (Google's Static Maps) or its tiles put together (Static\TileStitcher). A $scale of 2 gives an image of twice the density, $size x 2 pixels. The image is no larger than asked: a provider with a limit (Google: 640 pixels a side at scale 1) answers smaller, Image::$size says how large.

GeocoderInterface

geocode(string $address, ?string $locale = null, int $limit = 5): list<Place> - places for an address, the best first, at most $limit; nothing found is an empty list, not an exception. reverse(LatLng $position, ?string $locale = null, int $limit = 5): list<Place> - addresses at a point. $locale is the language of the answer (en, ja), the provider's default when null. Both go through geocoder-php (Geocoding).

TileSourceInterface

Square tiles of the Web Mercator grid, one URL each: what Leaflet draws and what TileStitcher puts together.

Method
template(): string the URL template, {z}, {x} and {y} in it (and {s} for a server's subdomains): the browser's library fills it
url(int $zoom, int $x, int $y): string one tile's complete URL
tileSize(): int in pixels: 256 nearly always (the conformance suite accepts 256 and 512)
maxZoom(): int the deepest zoom served, 1 to 24
attribution(): string as HTML
headers(): array<string, string> the headers a request for a tile must carry (a User-Agent naming the application); may throw when they cannot be given

Models

All models are final readonly classes in Omnimap\Model. Coordinates are degrees (WGS 84), sizes and positions pixels, zooms the Web Mercator zoom every provider shares (0 is the whole world in one 256-pixel tile). Every model but Point and Image implements JsonSerializable with the same array as its toArray().

LatLng

A point on Earth.

Property Type
lat float -90 to 90: anything else, or a value that is not finite, throws InvalidArgumentException
lng float any finite value: 180.5 and -179.5 are the same meridian
Method
LatLng::fromArray(['lat' => ..., 'lng' => ...]) from an array; numeric strings accepted
wrapped(): LatLng the same point, its longitude between -180 (included) and 180 (excluded); MapView::toArray(), hence every description, carries the centre and the markers wrapped, as schema/map-description.schema.json asks (new LatLng(10, 190) is described lng: -170)
equals(LatLng $other, float $tolerance = 1e-9): bool both coordinates within the tolerance
toArray(): array{lat, lng}
__toString() "lat,lng" with at most 7 decimals and no trailing zeros (35.0116,135.7681), as URLs and geocoders write a point

Bounds

A box on the map, from its south-west to its north-east corner. A box across the antimeridian has a western longitude greater than its eastern one.

Property / method
southWest, northEast LatLng
contains(LatLng $point): bool latitude between the corners; longitude compared wrapped, across the antimeridian too
toArray() ['south' => float, 'west' => float, 'north' => float, 'east' => float]

Size and Point

Size(int $width, int $height): pixels, at least 1 each way (InvalidArgumentException otherwise). Size::fromString('640x480') reads WIDTHxHEIGHT, scaled(int $scale) multiplies both sides, (string) writes 640x480, jsonSerialize() gives ['width' => ..., 'height' => ...].

Point(float $x, float $y): a position in pixels, on the whole world at a zoom (Projection\WebMercator) or on an image.

Marker

A pin on the map.

Property Type Default
position LatLng -
title ?string null shown on hover by the drivers that can (Leaflet's tooltip, Google's title, MapLibre's label)
color ?string null a CSS hex colour (#d33b3b): the dot's colour on an image made of tiles, Leaflet's and MapLibre's marker colour, Google's Static Maps marker colour; the Google live driver does not use it

toArray() gives ['position' => [...], 'title' => ..., 'color' => ...].

MapView

What a map shows, whoever draws it.

Property Type Default
center LatLng -
zoom int - 0 to 24 (InvalidArgumentException otherwise)
markers list<Marker> []
controls bool true whether the provider's own controls are shown (Google's default UI, Leaflet's zoom control, MapLibre's navigation control and its interactivity)
options array<string, array> [] the options only one provider understands, under its name: ['google' => ['styles' => [...]]]; another provider ignores them
id string 'map' the page element the map is drawn into
Method
withMarker(Marker $marker): MapView a copy with one more marker
withMarkers(list<Marker> $markers): MapView a copy with these markers instead
withOptions(string $provider, array $options): MapView a copy with $options merged over that provider's options already set (array_replace)
options(string $provider): array one provider's options ([] when none)
toArray(): array ['id', 'center', 'zoom', 'markers', 'controls']: the view of every description; options are not in it, each provider puts what it reads of them in its description's options

Place and Address

A place a geocoder found.

Property Type
position LatLng
label string the provider's one-line name of the place (Google's formatted address, Nominatim's display name), else the address's parts joined: street number + street, postcode + locality, region, country
providedBy string geocoder-php's provider name (google_maps, nominatim): a service's terms often ask that its answers be shown with its name, and some forbid showing them on another provider's map
address ?Address
bounds ?Bounds the box the place covers, when the provider gives one
id ?string the provider's id of the place: Google's place_id; OpenStreetMap's object as type/id (relation/357794)

Address holds what a geocoder says of the address, each part ?string, null when unknown: streetNumber, streetName, postalCode, locality, subLocality, region (the first administrative level), country (its name), countryCode.

Image

Image(string $bytes, string $mimeType, Size $size): an image a provider made, its media type as the bytes are (image/png) and its size in pixels.

The projection

Omnimap\Projection\WebMercator is EPSG:3857, the projection of Google's, OpenStreetMap's and nearly every web map's tiles: at zoom z the world is a square of 256 x 2^z pixels, x growing eastward from the antimeridian, y southward from latitude 85.0511 (the square's edge).

Member
TILE 256
MAX_LATITUDE 85.0511287798066: latitudes beyond are drawn on the edge
worldSize(float $zoom): float the world's width at a zoom, in pixels
point(LatLng $latLng, float $zoom): Point where a point lies on the world, in pixels
latLng(Point $point, float $zoom): LatLng the point at a position (its longitude not wrapped)
bounds(LatLng $center, int $zoom, Size $size): Bounds the box an image of that size shows around that centre

The tile holding a point at zoom z is floor(point->x / 256), floor(point->y / 256) (Tiles).

The signer

Omnimap\Signer(?string $secret) signs with HMAC-SHA1, the secret given in URL-safe base64, the signature in URL-safe base64: Google's rule for signing a Maps URL, and the rule glitchr/ux-google named its captured maps by. An empty or null secret signs with an empty key, as ux-google did.

Method
sign(string $payload): string the signature without base64's = padding: the map cache's names (27 characters)
signPadded(string $payload): string with its padding: what Google expects in a URL's signature=
verify(string $payload, string $signature): bool constant-time comparison, padding ignored
Signer::encode(string $bytes), Signer::decode(string $text) URL-safe base64 (- and _ for + and /)

The map cache, in short

Snapshot\Snapshots names a view (signature()), finds its picture, makes it with a StaticMapInterface or keeps one the browser captured; Snapshot\SnapshotStore cuts and keeps pictures in a Snapshot\Storage; Snapshot\Snapshot describes one kept picture; MapHtml and MapPrinter print the page's element. All of it is in The map cache.

Proposer une modification docs/concepts.md

A live map in the browser

A renderer (MapRendererInterface) turns a MapView into a description; the page carries it in a data- attribute; assets/omnimap.js reads it and hands it to the provider's driver, which loads the provider's library and draws the map once the visitor's consent allows it. PHP sends no code to the browser and calls nothing: describing a view is plain data.

Describing a view

<?php

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

use Omnimap\MapHtml;
use Omnimap\MapLibre\MapLibreFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;

$registry = new Registry([new OpenStreetMapFactory(), new MapLibreFactory()], [
    'maps' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
    'vector' => ['factory' => 'maplibre'],
]);

$view = (new MapView(new LatLng(48.5818, 7.7509), 14, [new Marker(new LatLng(48.5818, 7.7509), 'Cathédrale', '#1a73e8')], id: 'map'))
    ->withOptions('openstreetmap', ['map' => ['scrollWheelZoom' => false]])
    ->withOptions('maplibre', ['map' => ['pitch' => 45]]);

$description = $registry->renderer('maps')->describe($view);
echo json_encode($description, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n\n";
echo (new MapHtml())->live('map', $description), "\n";
echo json_encode($registry->renderer('vector')->describe($view)['options'], JSON_UNESCAPED_SLASHES), "\n";
echo $registry->renderer('maps')->fingerprint($view), "\n";
{
    "provider": "openstreetmap",
    "consent": "MAP",
    "view": {
        "id": "map",
        "center": {
            "lat": 48.5818,
            "lng": 7.7509
        },
        "zoom": 14,
        "markers": [
            {
                "position": {
                    "lat": 48.5818,
                    "lng": 7.7509
                },
                "title": "Cathédrale",
                "color": "#1a73e8"
            }
        ],
        "controls": true
    },
    "library": {
        "script": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
        "scriptIntegrity": "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=",
        "style": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
        "styleIntegrity": "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
    },
    "tiles": {
        "template": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        "maxZoom": 19,
        "tileSize": 256,
        "attribution": "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"
    },
    "options": {
        "map": {
            "scrollWheelZoom": false
        }
    },
    "attribution": "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors"
}

<div id="map" data-omnimap="map" data-omnimap-description="{&quot;provider&quot;:&quot;openstreetmap&quot;,&quot;consent&quot;:&quot;MAP&quot;,&quot;view&quot;:{&quot;id&quot;:&quot;map&quot;,&quot;center&quot;:{&quot;lat&quot;:48.5818,&quot;lng&quot;:7.7509},&quot;zoom&quot;:14,&quot;markers&quot;:[{&quot;position&quot;:{&quot;lat&quot;:48.5818,&quot;lng&quot;:7.7509},&quot;title&quot;:&quot;Cathédrale&quot;,&quot;color&quot;:&quot;#1a73e8&quot;}],&quot;controls&quot;:true},&quot;library&quot;:{&quot;script&quot;:&quot;https://unpkg.com/leaflet@1.9.4/dist/leaflet.js&quot;,&quot;scriptIntegrity&quot;:&quot;sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=&quot;,&quot;style&quot;:&quot;https://unpkg.com/leaflet@1.9.4/dist/leaflet.css&quot;,&quot;styleIntegrity&quot;:&quot;sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=&quot;},&quot;tiles&quot;:{&quot;template&quot;:&quot;https://tile.openstreetmap.org/{z}/{x}/{y}.png&quot;,&quot;maxZoom&quot;:19,&quot;tileSize&quot;:256,&quot;attribution&quot;:&quot;&amp;copy; &lt;a href=\&quot;https://www.openstreetmap.org/copyright\&quot;&gt;OpenStreetMap&lt;/a&gt; contributors&quot;},&quot;options&quot;:{&quot;map&quot;:{&quot;scrollWheelZoom&quot;:false}},&quot;attribution&quot;:&quot;&amp;copy; &lt;a href=\&quot;https://www.openstreetmap.org/copyright\&quot;&gt;OpenStreetMap&lt;/a&gt; contributors&quot;}"></div>

{"map":{"pitch":45},"preserveDrawingBuffer":true}
openstreetmap|["https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png",{"lat":48.5818,"lng":7.7509},14,[{"position":{"lat":48.5818,"lng":7.7509},"title":"Cathédrale","color":"#1a73e8"}],true]

(run on 2026-10-25 in the harness)

The same view went to two providers: each read its own options (openstreetmap's map, maplibre's map) and ignored the other's. No factory was given an HTTP client: describing needs none.

The description

What every renderer returns, as schema/map-description.schema.json (JSON Schema 2020-12) defines it; Tests/MapDescriptionSchemaTest.php checks every installed renderer's description against it.

Key Type Required
provider string, ^[a-z0-9][a-z0-9_-]*$ yes the provider's name: the driver omnimap.js picks (register(name, driver))
consent string, not empty yes the consent feature the map waits for before reaching a third party: MAP for every provider today
view object yes MapView::toArray(): id, center (lat -90..90, lng -180..180), zoom (0..24), markers (each position, title, color, the last two nullable), controls; no other key
attribution string yes HTML shown beside a kept picture of the map; may be empty when the live map shows its own
library object of strings no where the browser loads the provider's library: script, style, module, their integrity, a key
options object no the provider's options for its library, from MapView::options(<provider>)

Any other key is the provider's own (tiles for OpenStreetMap, style for MapLibre). The schema's $id is https://raw.githubusercontent.com/glitchr-omnisense/omnimap/1.x/schema/map-description.schema.json.

Each provider's keys:

Provider library Own keys options attribution
google key (api_key), v (version), language, region - the empty ones left out - styles (list, []), zoomStyles (object or null), map (object, {}) '': the live map draws Google's own
openstreetmap script, scriptIntegrity, style, styleIntegrity (Leaflet; the empty ones left out) tiles: template, maxZoom, tileSize, attribution map (object, {}) the attribution option
maplibre script, scriptIntegrity, style, styleIntegrity; or module, style, styleIntegrity when library_module is set style: the style's URL map (object, {}), preserveDrawingBuffer (bool) the attribution option

A view's options of one provider

MapView::$options holds, under a provider's name, what only that provider understands; each renderer reads its own and ignores the others. What each reads:

Provider Key
google styles a list of {name, rules: [{featureType, elementType, stylers: [{color: '#99d7fe'}]}], baseMapTypeId}: each becomes a google.maps.StyledMapType registered under its name (baseMapTypeId roadmap by default)
google zoomStyles {default: <style name>, below: {4: <name>, 7: <name>}}: the map type shown, below giving the style shown under each zoom (the first stop above the current zoom wins); without below, default is applied once
google map any other google.maps.MapOptions, as JSON, spread over the driver's center, zoom and disableDefaultUI
openstreetmap map Leaflet's map options, spread over zoomControl and attributionControl
maplibre map MapLibre's map options, spread over the driver's own, and part of the picture's fingerprint

Google's Static Maps also reads styles: the rules of the zoomStyles.default style (the first style when there is no default) are sent as style= parameters (Images).

Printing it

MapHtml::live(string $id, array $description): string prints <div id="..." data-omnimap="map" data-omnimap-description="...">: the description as JSON (slashes and Unicode unescaped), HTML-escaped into the attribute. The element is empty: give it a height in CSS. mountAll() then draws every such element under a root (The browser).

In Symfony and Laravel, the Twig function omnimap(view) prints it (or the cached picture, see The map cache).

A live map loads a third party's script and tiles from the visitor's browser: the hosts are in capabilities()->browserHosts (for a consent banner and a Content-Security-Policy), and the driver runs only through the consent(feature, load) gate given to mountAll(), with the description's consent as feature. Without a gate, the map loads at once. omnibase/consent's Consent.use('MAP', {}, load) is one such gate. A page that must reach no third party at all shows the cached picture instead (The map cache).

The fingerprint

fingerprint(MapView) is the text a picture of the view is named after, once signed. What each provider puts in it:

Provider Fingerprint
google `google
openstreetmap `openstreetmap
maplibre `maplibre
test (TestTiles) `test

The view's id is in none: the same view printed in two elements has one picture.

Proposer une modification docs/usage-rendering.md

Images made on the server

A static map (StaticMapInterface) makes an image of a view on the server: for an e-mail, a PDF, a page that must reach no third party, or the map cache, which keeps such an image as the view's picture. Two ways exist today: a provider's static map service (Google's Static Maps) and tiles put together by Omnimap\Static\TileStitcher (OpenStreetMap, any tile source).

An image

<?php

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

use Omnimap\Google\GoogleFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Marker;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;

// Tiles answered here (PlainTiles); HttpClient::create() in production.
$http = new RecordedHttpClient(PlainTiles::answer());
$cache = new ArrayCache();
$osm = (new OpenStreetMapFactory($http, null, $cache))->create(['user_agent' => 'MySite/1.0 (+https://example.org; maps@example.org)']);

$view = new MapView(new LatLng(35.0116, 135.7681), 12, [new Marker(new LatLng(35.0036, 135.7780), 'Gion')]);
$image = $osm->image($view, new Size(640, 400), 2);
file_put_contents(sys_get_temp_dir().'/kyoto.png', $image->bytes);

echo $image->mimeType, ' ', $image->size, ', ', count($http->requests), " tiles fetched\n";
echo $http->requests[0]['url'], ' ', $http->requests[0]['options']['headers']['User-Agent'], "\n";
echo 'kept ', $cache->ttls[array_key_first($cache->ttls)], " s\n";
$osm->image($view, new Size(640, 400), 2);
echo count($http->requests), " tiles fetched after a second image\n";

$google = (new GoogleFactory())->create(['api_key' => 'browser-key', 'server_key' => 'server-key', 'secret' => 'vNIXE0xscrmjlyV-12Nj_BvUPaw=']);
echo $google->staticUrl($view, new Size(1512, 938), 2), "\n";
image/png 1280x800, 30 tiles fetched
https://tile.openstreetmap.org/13/7182/3242.png MySite/1.0 (+https://example.org; maps@example.org)
kept 695880 s
30 tiles fetched after a second image
https://maps.googleapis.com/maps/api/staticmap?center=35.0116,135.7681&zoom=12&size=640x397&scale=2&markers=35.0036%2C135.778&key=server-key&signature=rUXZxBfvqM0gblKazD4BVDZ7KaQ=

(run on 2026-10-25 in the harness; the tiles came from PlainTiles with OpenStreetMap's own Cache-Control of 2026-10-23, max-age=91080, stale-while-revalidate=604800)

At scale 2 the stitcher took the tiles of zoom 13 for a view at zoom 12, and kept each tile for max-age plus stale-while-revalidate (91080 + 604800 = 695880 seconds); the second image fetched nothing. Google's URL was built without any request: staticUrl() is public.

Tiles put together: TileStitcher

new TileStitcher(TileSourceInterface $tiles, HttpClientInterface $http, ?CacheInterface $cache = null, string $background = '#aad3df', string $markerColor = '#d33b3b'), then image(MapView $view, Size $size, int $scale = 1): Image:

  • the scale is kept between 1 and 2; the zoom is the view's plus scale - 1, at most the source's maxZoom(); the image is $size x scale pixels, centred on the view's centre;
  • a 512-pixel source (tileSize() 512) numbers its tiles as such servers do, 2^z a side at zoom z (Leaflet's zoomOffset: -1): the tiles of the zoom below are taken (a view at zoom 1 is the one tile 0/0/0), and at zoom 0 that tile is drawn at half its size;
  • every tile the image covers is listed once; above and below the world (y outside the grid) the background colour shows; east and west the world repeats (x taken modulo the grid);
  • tiles found in the PSR-16 cache (key omnimap.tile. + SHA-1 of the URL) are not fetched; the others are requested all at once (the HTTP client runs them side by side) with the source's headers(); each answer is kept for max-age plus stale-while-revalidate of its Cache-Control, or 604800 seconds (7 days) when it gives neither (TileStitcher::lifetime(), public);
  • a tile answered with a status other than 200 is a ProviderException, a transport failure an UnreachableException; the tiles still on their way are cancelled. A body GD cannot read as an image is a ProviderException too;
  • each marker is drawn as a dot of radius 6 x scale in its color (else markerColor), ringed in white; the marker's title is not drawn;
  • the source's attribution, its tags stripped and its entities decoded, is written in the bottom-right corner on a translucent white band with GD's built-in font (© written (c), other non-ASCII characters dropped);
  • the result is a PNG.

Google Static Maps

GoogleProvider::image() fetches staticUrl($view, $size, $scale):

  • size clamped to 640 pixels on the longer side, the ratio kept (1512 x 938 becomes 640 x 397); scale kept between 1 and 2;
  • one markers= per marker, color:0x...| before its position when it has a colour;
  • one style= per rule of the view's default Google style (A live map);
  • language and region when set, then key (the server_key, else the api_key; neither is an InvalidConfigException), and with a secret the signature= of the path and query, HMAC-SHA1 padded (Google's URL signing).

The answer must be 200 and its bytes an image (their media type is read from the bytes, not from the Content-Type): anything else is a ProviderException, a transport failure an UnreachableException. Image::$size is the clamped size times the scale.

Without an HTTP client

A provider built without its HTTP client declares staticMap off and its image() throws NotSupportedException before any request. omnimap/maplibre makes no image at all: vector tiles are drawn by the browser's WebGL. Its picture reaches the map cache only through the browser's capture (The map cache).

Proposer une modification docs/usage-static.md

Geocoding

A geocoder (GeocoderInterface) finds places from an address (geocode()) and addresses from a point (reverse()). omnimap does not read a geocoding API itself: each provider puts one of geocoder-php's providers (willdurand/geocoder) behind Omnimap\Geocoding\GeocoderPhp, which turns its answers into omnimap's Places, keeps them in a PSR-16 cache and paces the calls. The providers that geocode take a PSR-18 client for geocoder-php, beside their Symfony HTTP client.

Places from an address, an address from a point

<?php

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

use Omnimap\Google\GoogleFactory;
use Omnimap\Model\LatLng;
use Omnimap\Testing\ArrayCache;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\Response\MockResponse;

// Google's answer, written here; HttpClient::create() in production.
$answer = json_encode(['status' => 'OK', 'results' => [[
    'formatted_address' => 'Osaka, Japan',
    'place_id' => 'ChIJ4eIGNFXmAGAR5y9q5G7BW8U',
    'address_components' => [
        ['long_name' => 'Osaka', 'short_name' => 'Osaka', 'types' => ['locality', 'political']],
        ['long_name' => 'Japan', 'short_name' => 'JP', 'types' => ['country', 'political']],
    ],
    'geometry' => ['location' => ['lat' => 34.6937378, 'lng' => 135.5021651], 'location_type' => 'APPROXIMATE'],
    'types' => ['locality', 'political'],
]]]);
$requests = 0;
$http = new MockHttpClient(function (string $method, string $url) use ($answer, &$requests) {
    ++$requests;
    echo "GET ", preg_replace('~key=[^&]+~', 'key=…', $url), "\n";

    return new MockResponse($answer, ['response_headers' => ['content-type' => 'application/json']]);
});

$google = (new GoogleFactory($http, new Psr18Client($http), new ArrayCache()))->create(['server_key' => 'server-key', 'language' => 'en']);

foreach ($google->geocode('Osaka', limit: 1) as $place) {
    echo $place->label, ' ', $place->position, ' ', $place->address->countryCode, ' ', $place->id, ' (', $place->providedBy, ")\n";
}
$google->geocode('Osaka', limit: 1);
echo $requests, " request\n";
echo count($google->reverse(new LatLng(34.6937, 135.5022), 'ja', 1)), " place\n";
GET https://maps.googleapis.com/maps/api/geocode/json?address=Osaka&language=en&key=…
Osaka, Japan 34.6937378,135.5021651 JP ChIJ4eIGNFXmAGAR5y9q5G7BW8U (google_maps)
1 request
GET https://maps.googleapis.com/maps/api/geocode/json?latlng=34.693700,135.502200&language=ja&key=…
1 place

(run on 2026-10-25 in the harness, with nyholm/psr7 installed for Psr18Client; the answer is written here, Google was not called)

The second geocode('Osaka') was answered by the cache. The locale given (ja) went to Google instead of the provider's language. In production the client is HttpClient::create() (or the application's) and the cache the application's PSR-16 pool.

What GeocoderPhp does

new GeocoderPhp(Geocoder\Provider\Provider $provider, ?CacheInterface $cache = null, int $ttl = 2592000, float $interval = 0.0):

  • geocode($address, $locale, $limit) asks GeocodeQuery::create($address)->withLimit($limit) (with the locale when not null); reverse($position, $locale, $limit) asks ReverseQuery::fromCoordinates();
  • the cache: the answer is kept under omnimap.geocoder. + SHA-1 of the provider's name, the question (geocode|<address> or reverse|<lat,lng>), the locale and the limit, for $ttl seconds (30 days by default: Nominatim's policy asks for results cached on the caller's side). A cached answer is returned without waiting and without a call; an empty answer is kept too;
  • the pace: before a call, it waits until $interval seconds have passed since the end of the previous call of this object (usleep). It paces one PHP process; across processes, a shared cache is what keeps the count down;
  • the answer: each geocoder-php Location with coordinates becomes a Place (see Concepts); one without coordinates is dropped;
  • the errors: a PSR-18 NetworkExceptionInterface is an UnreachableException; geocoder-php's own exceptions (an error status, an invalid answer, a quota) and other PSR-18 client exceptions are a ProviderException; a TypeError, ValueError or JsonException raised while geocoder-php reads a body it did not expect is a ProviderException too. Each message names the provider and the question.

GeocoderPhp::place(Location $location): ?Place is public: it converts one geocoder-php answer.

The providers that geocode

Provider geocoder-php provider Needs Locale Pace
google Geocoder\Provider\GoogleMaps\GoogleMaps (geocoder-php/google-maps-provider), with region and the server key a PSR-18 client, and server_key or api_key the call's, else the language option none
openstreetmap Geocoder\Provider\Nominatim\Nominatim (geocoder-php/nominatim-provider), at nominatim with user_agent and referer a PSR-18 client, a user_agent, a non-empty nominatim URL the call's nominatim_interval (1 second)
maplibre - - - -

Both keep answers for geocoder_ttl seconds (30 days) in the cache given to their factory's constructor (none: no cache). Without what they need, capabilities()->geocoder is false and geocode() / reverse() throw NotSupportedException before any request.

A geocoder of one's own choice

Any geocoder-php provider can sit behind GeocoderPhp (Here, Mapbox, Photon, a chain of several); GeocoderPhp is not a GeocoderInterface itself (it is not a provider: it has no name, no capabilities). A provider package exposes it: omnimap/openstreetmap's provider takes any GeocoderPhp in its constructor, which is also how an application's test gives it a fixed answer (Testing).

use Geocoder\Provider\Photon\Photon;                      // geocoder-php/photon-provider
use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\OpenStreetMap\OpenStreetMapProvider;

$geocoder = new OpenStreetMapProvider(geocoder: new GeocoderPhp(Photon::withKomootServer($psr18), $cache, 86400, 1.0));
$geocoder->geocode('Strasbourg');

(not run: geocoder-php/photon-provider is not installed in the harness)

Terms

Nominatim's policy (https://operations.osmfoundation.org/policies/nominatim/): at most one request a second, an identifying User-Agent or Referer, results cached, no autocomplete, no systematic queries. Google's terms (https://cloud.google.com/maps-platform/terms) restrict caching Geocoding results and showing them on a map other than Google's: Place::$providedBy says whose answer a place is.

Proposer une modification docs/usage-geocoding.md

Tiles

A tile source (TileSourceInterface) gives the square tiles of the Web Mercator grid, one URL each: what a browser library draws (Leaflet fills template() itself) and what Static\TileStitcher fetches on the server. Of the providers, omnimap/openstreetmap is one, over any raster tile server; the core's Testing\TestTiles is another.

<?php

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

use Omnimap\Model\LatLng;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Projection\WebMercator;
use Omnimap\Registry;

$registry = new Registry([new OpenStreetMapFactory()], [
    'osm' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0 (+https://example.org)']],
]);
$tiles = $registry->tiles('osm');

$point = WebMercator::point(new LatLng(35.0116, 135.7681), 12);
[$x, $y] = [intdiv((int) $point->x, $tiles->tileSize()), intdiv((int) $point->y, $tiles->tileSize())];

echo $tiles->template(), "\n";
echo $tiles->url(12, $x, $y), "\n";
echo $tiles->tileSize(), ' ', $tiles->maxZoom(), ' ', json_encode($tiles->headers()), "\n";
https://tile.openstreetmap.org/{z}/{x}/{y}.png
https://tile.openstreetmap.org/12/3592/1622.png
256 19 {"User-Agent":"MySite\/1.0 (+https:\/\/example.org)"}

(run on 2026-10-25 in the harness; nothing was fetched)

What a tile source answers

  • template(): the URL with {z}, {x}, {y} (and {s}, a server's subdomain, when the server has several). Leaflet fills it in the browser; the description carries it.
  • url($zoom, $x, $y): one tile's complete URL. omnimap/openstreetmap replaces {s} with a, b or c by (x + y) mod 3, so a tile always has the same address (and the same cache entry).
  • tileSize(): 256 for nearly every server; 512 for a server of 512-pixel tiles numbered 2^z a side at zoom z, which TileStitcher and leaflet.js read at the zoom below the view's (Leaflet's zoomOffset: -1).
  • maxZoom(): the deepest zoom served; TileStitcher never asks beyond it: a deeper view is made at that zoom, its tiles not enlarged, so the image shows a wider area than the view.
  • attribution(): HTML, the attribution the tiles' licence asks for.
  • headers(): what every server-side request for a tile carries. omnimap/openstreetmap gives its user_agent and, when set, its referer; without a user_agent it throws InvalidConfigException, so that no tile is fetched with a library's default User-Agent (the OpenStreetMap Foundation's tile policy blocks those). Without one its capabilities()->staticMap is off and image() throws NotSupportedException.

Fetching a tile oneself

The URLs are plain: any HTTP client fetches them with headers(). TileStitcher already fetches, caches and puts tiles together; fetch one directly only to proxy tiles, and then keep the server's caching headers. The OpenStreetMap Foundation's policy forbids bulk downloads and prefetching, and asks heavy users for another provider or their own server (https://operations.osmfoundation.org/policies/tiles/).

Proposer une modification docs/usage-tiles.md

The map cache

A map drawn live costs the visitor a third party's script and tiles - their address sent to Google or to a tile server, a consent to ask, a quota spent. The cache shows a picture of the map instead: made once, kept in the host's storage, served by the host. It is the cache glitchr/ux-google kept for lesringostars, made independent of the provider: the same for Google, OpenStreetMap and MapLibre, only the source of the picture differs.

A whole example

<?php

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

use Omnimap\MapHtml;
use Omnimap\MapPrinter;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;

$renderer = new TestTiles(new RecordedHttpClient(PlainTiles::answer()));   // a renderer that makes no image here
$dir = sys_get_temp_dir().'/maps';
$store = new SnapshotStore(new DirectoryStorage($dir), 'omnimap', '{signature}/image-{id}', 'png');
$snapshots = new Snapshots($renderer, new Signer('c2VjcmV0'), $store);    // no StaticMapInterface: pictures come from the browser
$printer = new MapPrinter($snapshots, new MapHtml(), cacheOnly: true);

$view = new MapView(new LatLng(35.0116, 135.7681), 12, id: 'map');
$signature = $snapshots->signature($view);

echo $printer->map($view), "\n";                     // a visitor, nothing kept yet: an empty frame
echo substr($printer->map($view, mayChange: true), 0, 40), "...\n";      // whoever may change it: the live map
echo $printer->exportButton($view, true, "/omnimap/$signature/export", 'token', 'Save'), "\n";

// What the export route receives from capture.js: a data: URL.
$capture = 'data:image/png;base64,'.base64_encode(PlainTiles::png(12, 0, 0, 1000));
$snapshot = $snapshots->save($signature, $capture, 512);
echo json_encode($snapshot), "\n";
echo implode(' ', array_map('basename', glob("$dir/omnimap/$signature/*"))), "\n";
echo $printer->map($view), "\n";                     // now the picture, for everyone
echo $printer->suppressButton($view, true, "/omnimap/$signature/suppress", 'token', 'Delete'), "\n";

$snapshots->delete($signature);
var_dump($snapshots->find($view));
<div id="map">
<div class="omnimap-snapshot" id="map_snapshot" data-omnimap="snapshot" data-src="/omnimap/%7Bsignature%7D/%7Bid%7D.webp" data-signature="0xkZGhEUccy1p6qE0cE-IRmuZgg" data-tilesize="" data-xtiles="0" data-ytiles="0" data-missing="" width="0" height="0"></div>
<div class="omnimap-attribution" style="position:absolute;right:0;bottom:0;z-index:1;padding:0 5px;font:11px/1.5 sans-serif;color:#333;background:rgba(255,255,255,.75)">&copy; Test tiles</div>
</div>

<div id="map" data-omnimap="map" data-om...
<button type="button" data-omnimap-export="/omnimap/0xkZGhEUccy1p6qE0cE-IRmuZgg/export" data-omnimap-target="map" data-omnimap-token="token">Save</button>
{"signature":"0xkZGhEUccy1p6qE0cE-IRmuZgg","width":1000,"height":1000,"tilesize":512,"xtiles":2,"ytiles":2}
image-0.png image-1.png image-2.png image-3.png metadata.txt
<div id="map">
<div class="omnimap-snapshot" id="map_snapshot" data-omnimap="snapshot" data-src="/omnimap/%7Bsignature%7D/%7Bid%7D.webp" data-signature="0xkZGhEUccy1p6qE0cE-IRmuZgg" data-tilesize="512" data-xtiles="2" data-ytiles="2" data-missing="" width="1000" height="1000"></div>
<div class="omnimap-attribution" style="position:absolute;right:0;bottom:0;z-index:1;padding:0 5px;font:11px/1.5 sans-serif;color:#333;background:rgba(255,255,255,.75)">&copy; Test tiles</div>
</div>

<button type="button" data-omnimap-suppress="/omnimap/0xkZGhEUccy1p6qE0cE-IRmuZgg/suppress" data-omnimap-target="map" data-omnimap-token="token">Delete</button>
NULL

(run on 2026-10-25 in the harness)

A name per view

Snapshots::signature($view) = Signer::sign($renderer->fingerprint($view)): HMAC-SHA1 of what the picture depends on, with the host's secret (URL-safe base64, as Google gives its URL signing secret), in URL-safe base64 without padding - 27 characters. Change the centre, the zoom, the markers (all providers, but Google with legacy_signature) or the provider, and the name changes: a new picture is needed, the old one is not served for the new view. What each provider puts in its fingerprint is listed in A live map.

omnimap/google's legacy_signature option fingerprints a view as ux-google did (the Map's class and its zoom, center, html2canvas, disableDefaultUI options; markers and styles not included), so the pictures it captured keep their names.

A signature is checked before it reaches a storage path: only [A-Za-z0-9_-]{1,128} is accepted (InvalidArgumentException otherwise), so no / or .. gets in.

The layout in the storage

SnapshotStore($storage, $directory, $pattern, $format):

<directory>/<signature>/image-<n>.<format>     the tiles, n = y * xtiles + x
<directory>/<signature>/metadata.txt           serialize(['status' => 'OK', 'image_width' => int,
                                               'image_height' => int, 'image_tilesize' => '512',
                                               'image_xtiles' => float, 'image_ytiles' => float])

metadata.txt keeps ux-google's keys and types (the tile size a string, the counts floats), so a capture it wrote is read as it is and one omnimap writes is read by ux-google. Tiles are squares of tilesize (512), the last row and column padded with transparency; with a tile size of null the picture is kept whole as tile 0.

Argument Default
$storage - a Snapshot\Storage
$directory omnimap the folder under the storage's root; '' for the root itself (lesringostars keeps google)
$pattern {signature}/image-{id} a tile's path under $directory, without its extension
$format png png, jpeg, jpg or webp (else InvalidArgumentException): the tiles' encoding (JPEG at quality 92, WebP at 90) and their extension
Method
find(string $signature): ?Snapshot the picture kept: its metadata.txt read and unserialized (no classes allowed); null when it is missing, unreadable or its status is not OK
tile(string $signature, int $id): ?string a tile's bytes in the store's format; null when there is none (or $id < 0)
save(string $signature, string $image, ?int $tilesize = 512): Snapshot cuts an image GD can read into tiles (or keeps it whole with null or a size below 1), replacing what was kept under that name; not an image: InvalidArgumentException
delete(string $signature): void the picture's folder, tiles and metadata
format(), tilePath($signature, $id), metadataPath($signature) the format, and where a tile and the metadata are

What find() answered is remembered for the store's life (one storage round trip per question: on S3 each is an HTTP request); save() and delete() update it.

Snapshot is the kept picture: signature, width, height (pixels), tilesize (?int, null for a whole picture), xtiles, ytiles; tiles() is their product, metadata() the array written to metadata.txt, Snapshot::fromMetadata($signature, $array) reads one back (missing counts read as 1, a missing size as 0).

The storage is an interface of four methods (Snapshot\Storage: read, write, exists, deleteDirectory); paths are relative and /-separated:

Storage Constructor
DirectoryStorage (string $root) a directory of the server's disk; directories created 0775; a path holding .. is an \InvalidArgumentException
FlysystemStorage (League\Flysystem\FilesystemOperator $filesystem) any Flysystem 3 storage (local, S3, SFTP...): the operator a host already has; needs league/flysystem
DriveStorage (Omnidrive\Drive $drive) an omnisense/omnidrive Drive: the storage an application names, wherever it is; needs omnisense/omnidrive
the host's own implements Storage read() returns null for a missing file, deleteDirectory() does nothing for a missing folder

Where pictures come from

  • The browser: whoever may change the map sees the live map and an export button (MapHtml::button('export', ...), assets/capture.js): html2canvas draws the element, the picture is posted as a data: URL (image, tilesize, _token) to the host's export route, which checks the right and the token and calls Snapshots::save($signature, $image). That is how Google's map is cached: Google's Static Maps are limited to 640 pixels a side, and its terms limit what may be kept of its content - whoever caches it answers for that. MapLibre's map keeps its drawing buffer so that it can be captured the same way.
  • The server: given a StaticMapInterface, Snapshots::make($view) (or findOrMake()) asks the provider for an image of size x scale and cuts it. omnimap/openstreetmap makes it from its tile server's tiles (Static\TileStitcher): a 1512 x 938 picture at scale 1 is 35 tiles, fetched once with the application's User-Agent and kept as long as the server's Cache-Control allows.

new Snapshots(MapRendererInterface $renderer, Signer $signer, SnapshotStore $store, ?StaticMapInterface $staticMap = null, Size $size = new Size(1512, 938), int $scale = 1, ?int $tilesize = 512):

Method
signature(MapView $view): string the view's name
find(MapView $view): ?Snapshot the picture kept, or null
canMake(): bool whether a static map was given
make(MapView $view): Snapshot the picture made now by the server and kept, replacing any; without a static map a NotSupportedException (a \LogicException)
findOrMake(MapView $view): ?Snapshot the picture kept; made now when there is none and the server can make one; else null
save(string $signature, string $image, ?int $tilesize = null): Snapshot a picture the browser captured - an image's bytes, or a data: URL (its base64 after the comma, decoded strictly) - kept under the signature, cut in $tilesize (the constructor's when null)
delete(string $signature): void the picture forgotten
store(): SnapshotStore, renderer(): MapRendererInterface

save() takes the signature the page posts, not a view: the host's route must check who posts (the bridges allow control, ROLE_ADMIN, with a CSRF token), since anyone who can post may replace the picture of any name.

The page: MapPrinter

MapPrinter decides what a page shows of a view, whatever the host - glitchr/ux-google's rule (cache_only, cache_control), the provider aside:

  • the picture kept, when there is one - made now by the server when it can (findOrMake());
  • else, when $cacheOnly (the default) and the viewer may not change the map: the empty frame (no picture, no tile fetched, no third party reached) until a picture is captured;
  • else the live map.

new MapPrinter(Snapshots $snapshots, MapHtml $html = new MapHtml(), bool $cacheOnly = true, ?\Closure $onError = null):

Method
map(MapView $view, bool $mayChange = false): string the element, by the rule above
exportButton(MapView $view, bool $mayChange, string $url, string $token, string $contents, array $attributes = []): string the export button when $mayChange, a picture kept or not (the next capture replaces a kept one), else ''
suppressButton(...) (same arguments) the delete button when $mayChange and a picture is kept, else ''
signature(MapView $view): string, snapshots(): Snapshots

A picture the server could not make (a tile server down, a missing user_agent) is handed to $onError (the bridges log it as a warning) and the page shows no picture: map() never throws for it. Whether the viewer may change the map is the host's to say (omnibridge's Twig extension asks its Access for control).

The markup: MapHtml

new MapHtml(array $options = []), options class (omnimap-snapshot), suffix (_snapshot), src (/omnimap/{signature}/{id}.webp) and missing ('') - the host's own names where it has older ones to keep (lesringostars: class google-tilemap, suffix _html2canvas, src /google/{signature}/{id}.webp).

snapshot(string $id, string $signature, ?Snapshot $snapshot, string $attribution = '') prints the element $id holding the frame $id.$suffix and, when not empty, the attribution in a small absolutely positioned band (bottom right). The frame's attributes:

Attribute Value
class the class option
data-omnimap snapshot
data-src the src option, { and } written %7B and %7D (snapshot.js decodes them)
data-signature the view's signature
data-tilesize the picture's tile size; '' for a whole picture or none
data-xtiles, data-ytiles the grid; 0 when there is no picture (nothing is laid out, nothing fetched)
data-missing the missing option
width, height the picture's size; 0 when there is none

live(string $id, array $description) prints the live map's element (A live map). button(string $action, string $id, string $url, string $token, string $contents, array $attributes = []) prints a type="button" with data-omnimap-export (or data-omnimap-suppress when $action is suppress) set to $url, data-omnimap-target set to the map's element and data-omnimap-token, the extra attributes first. $contents is printed as it is: escape it.

The element is laid out by assets/snapshot.js and its tiles bound by assets/capture.js (The browser); give the outer element a size and position: relative in CSS so that the frame and the attribution band sit in it.

Serving

The page prints MapHtml::snapshot($id, $signature, $snapshot, $attribution): a frame with the picture's size, its grid and the URL template of a tile (/omnimap/{signature}/{id}.webp by default; a host keeps its own, lesringostars keeps /google/{signature}/{id}.webp). assets/snapshot.js lays the tiles out to cover the frame and loads each when it is on screen. The host's route reads SnapshotStore::tile($signature, $id) and answers the image: omnibridge/symfony converts it to WebP when the route's path ends in .webp and may write it under the public folder, so that the web server serves it without PHP the next time; omnibridge/laravel serves it as kept, with its own media type (Symfony, Laravel). No picture yet: the frame has no tile, nothing is fetched.

A route of one's own, in plain PHP:

// GET /omnimap/{signature}/{id}.webp
$bytes = $snapshots->store()->tile($signature, (int) $id);    // InvalidArgumentException for a bad signature
if (null === $bytes) {
    http_response_code(404);
    exit;
}
header('Content-Type: image/'.('jpg' === $snapshots->store()->format() ? 'jpeg' : $snapshots->store()->format()));
header('Cache-Control: public, max-age=86400');
echo $bytes;

Expiry

None by time: a picture is kept until it is deleted (Snapshots::delete(), the suppress button, or a new capture under the same name, which replaces it). A view that changes gets a new name; the old picture stays in the storage until deleted. The tiles a server-made picture was made of are kept in the PSR-16 cache for their own lifetime; the picture itself does not refresh: delete it to make it again from fresh tiles.

Proposer une modification docs/cache.md

The browser

Three ES modules, no library, no build step needed (a bundler takes them as they are): assets/omnimap.js draws a live map with a provider's driver, assets/snapshot.js lays a kept picture's tiles out, assets/capture.js sends a live map's picture to the cache. Each provider package ships its driver: omnimap/google's assets/google.js, omnimap/openstreetmap's assets/leaflet.js, omnimap/maplibre's assets/maplibre.js. Installed with Composer, they sit under vendor/omnisense/omnimap/assets/ and vendor/omnimap/<provider>/assets/: copy them to the public folder, map them in an import map, or let the application's bundler take them.

import { register, mountAll } from '/assets/omnimap/omnimap.js';
import google from '/assets/omnimap/google.js';
import leaflet from '/assets/omnimap/leaflet.js';
import maplibre from '/assets/omnimap/maplibre.js';
import { autoInit } from '/assets/omnimap/snapshot.js';
import { bindCapture } from '/assets/omnimap/capture.js';

register('google', google);
register('openstreetmap', leaflet);
register('maplibre', maplibre);
mountAll(document, { consent: (feature, load) => window.Consent ? Consent.use(feature, {}, load) : load() });
autoInit();
bindCapture(document, { html2canvas: window.html2canvas });

(the paths are where the host serves the files)

omnimap.js - a live map

import { register, mountAll } from '.../omnimap/assets/omnimap.js';
import google from '.../omnimap/google/assets/google.js';
import leaflet from '.../omnimap/openstreetmap/assets/leaflet.js';

register('google', google);
register('openstreetmap', leaflet);
mountAll(document, { consent: (feature, load) => window.Consent ? Consent.use(feature, {}, load) : load() });

mountAll() draws every [data-omnimap="map"] element (MapHtml::live()) from its data-omnimap-description. A driver is { draw(element, description, options) } returning the map; it gets loadScript() and loadStyle() in its options. The third party (Google's API, Leaflet and its tile server) is loaded through consent(feature, load), the feature being the description's consent (MAP): with omnibase/consent, Consent.use('MAP', ...).

Export
register(provider, driver) the driver of a provider's descriptions, by the provider's name (the description's provider); a second call replaces the first
draw(element, description, options = {}) draws one description in an element: a Promise of the driver's map
mountAll(root = document, options = {}) draws every [data-omnimap="map"] under root, once each: a Promise of all
loadScript(src, attributes = {}) adds <script async src> to the head once per src; a Promise resolved when it has run
loadStyle(href, attributes = {}) adds <link rel="stylesheet"> once per href

draw():

  • no driver registered for description.provider: the promise is rejected (omnimap: no driver registered for "x".);
  • the gate is options.consent, called as consent(description.consent || 'MAP', load); without one, load runs at once. Until the gate calls load, nothing is loaded and the promise stays pending;
  • load calls driver.draw(element, description, {...options, loadScript, loadStyle}); when the map is there, the element dispatches omnimap:ready (a CustomEvent that bubbles, detail: { map, description }) and the promise resolves with the map.

mountAll() marks each element data-omnimap-mounted="1" (a second call skips it), logs an unreadable data-omnimap-description to the console and skips the element, and on a failed draw logs the error and hides the element (display: none).

loadScript() sets each attribute whose value is truthy (an integrity, a crossorigin), gives the script the nonce of the page's first script[nonce] (a Content-Security-Policy with nonces), and rejects with omnimap: <src> could not load. on an error. loadStyle() does nothing when a stylesheet of that href is already in the document.

document.addEventListener('omnimap:ready', (event) => {
  const { map, description } = event.detail;   // a google.maps.Map, a Leaflet map, a maplibregl.Map
  console.log(description.provider, 'drawn in', event.target.id);
});

The drivers

A driver is a module whose default export is { draw }; draw(element, description, options) returns the map (or a promise of it). The three shipped:

google.js leaflet.js maplibre.js
library loaded Google's dynamic library loader (importLibrary('maps'), importLibrary('marker')) from https://maps.googleapis.com/maps/api/js, with description.library's keys as parameters (camelCase written snake_case) and the page's nonce; reused when google.maps.importLibrary exists library.style (with its integrity, crossorigin="anonymous" when it has one) and library.script, unless window.L exists; no script: omnimap: no Leaflet to load. library.style, then library.module (a dynamic import()) or library.script, unless window.maplibregl exists; neither: omnimap: no MapLibre GL JS to load.
map new Map(element, {center, zoom, disableDefaultUI: !controls, ...options.map}) L.map(element, {zoomControl: controls, attributionControl: true, ...options.map}), setView(); one L.tileLayer(tiles.template, {maxZoom, tileSize, attribution}) new maplibregl.Map({container, style, center, zoom, attributionControl: {compact: false}, preserveDrawingBuffer, interactive: controls, ...options.map}); a NavigationControl without compass when controls
markers google.maps.Marker with the position and title (the colour is not used) L.circleMarker, radius 6, white ring of 2, filled with the marker's colour (#d33b3b by default), its title as a tooltip maplibregl.Marker in the marker's colour (#d33b3b by default), its title as title and aria-label
view options styles (each a StyledMapType), zoomStyles (the map type by zoom, applied on idle and zoom_changed when below has stops), map map map
window events tilesloaded, idle, drag, each time the map fires them idle, once the map is ready idle, the first time the map is idle

The window events are glitchr/ux-google's: code that waited for Google's idle keeps working with the other drivers.

A driver of one's own:

export async function draw(element, description, { loadScript, loadStyle }) {
  loadStyle(description.library.style);
  await loadScript(description.library.script);
  const map = window.AcmeMaps.create(element, { center: description.view.center, zoom: description.view.zoom });
  description.view.markers.forEach((marker) => map.addPin(marker.position, marker.title));

  return map;
}

export default { draw };

(a sketch: AcmeMaps stands for the provider's own library)

snapshot.js - a kept picture

autoInit() lays out every [data-omnimap="snapshot"] frame once it is near the screen and shown, again on resize, and after transparent.js swaps a page in. initSnapshots(root), relayout(root) and layout(frame) for a host that drives it itself (React draws its own canvas from the same data instead).

Export
autoInit() initSnapshots() on load (at once when the document is complete), relayout() on resize, initSnapshots() again on transparent:ready and transparent:postactive
initSnapshots(root = document) observes each frame not yet laid out; lays it out when it comes within 200 pixels of the screen and is shown (not display: none, not visibility: hidden, with an offset parent unless fixed); without IntersectionObserver, lays every frame out at once
relayout(root = document) lays the frames already laid out and shown out again, then initSnapshots(root)
layout(frame) lays one frame's tiles out, creating them the first time; a frame that is not a div throws
COPYRIGHT_MARGIN 0.04: the share of the grid pushed below the frame's bottom edge, as ux-google's tilemap.js did

layout() reads the frame's attributes (The markup): data-src (URI-decoded), data-signature, data-xtiles, data-ytiles, width, height and data-tilesize (the larger of width and height when empty: a whole picture). No grid: nothing. It sets the frame centred and full-size (object-fit: cover), fits the picture's real size to cover the frame (the padding of the last row and column outside), widens the grid by COPYRIGHT_MARGIN and lowers its bottom by as much, then places one absolutely positioned <span id="<frame id>_<n>"> per tile with the tile's URL ({signature} and {id} replaced) in data-background-image. Each tile is loaded only once it is on screen (an IntersectionObserver; all at once without one), then shown as the span's background with a 0.25 s fade (none when the image was already in the browser's cache). The frame is marked data-omnimap-initialized="1".

data-missing (the missing option, html.missing in the bridges), when not empty, is the address of a picture shown for what is not there: over a frame with no picture kept (its background, cover, centred) and in place of a tile that fails to load. Empty, a frame with no picture stays empty and a failed tile transparent.

capture.js - sending a picture to the cache

bindCapture(document, { html2canvas, format: 'png', quality: 1, tilesize: 512 }) binds the buttons MapHtml::button() prints. omnimap ships no html2canvas: the host gives its own (1.x returns a promise; 0.4's onrendered is accepted too).

Option Default
html2canvas window.html2canvas the function that draws an element on a canvas
format png the picture's media type, image/<format>
quality 1 canvas.toDataURL()'s quality (JPEG, WebP)
tilesize 512 sent with the picture: the tile size the server cuts it into
html2canvasOptions {} merged over { useCORS: true }
  • [data-omnimap-export]: on click, draws the element named by data-omnimap-target and posts image (a data: URL), tilesize and _token (data-omnimap-token) to the URL in data-omnimap-export;
  • [data-omnimap-suppress]: on click, posts _token to the URL in data-omnimap-suppress.

Posts are fetch() POST, credentials: 'same-origin', application/x-www-form-urlencoded, Accept: application/json. The button's text turns #9ce62a99 (green) when the answer is JSON with status: "OK", grey otherwise (an HTTP error, no html2canvas, no target element). Each button is bound once (data-omnimap-bound="1"). The routes that receive the posts are the host's (Symfony); the server answers a capture with the picture's metadata, whose status is OK.

Data attributes and events

Attribute On Set by Read by
data-omnimap="map" live map element MapHtml::live() mountAll()
data-omnimap-description live map element MapHtml::live() mountAll()
data-omnimap-mounted live map element mountAll() mountAll()
data-omnimap="snapshot" picture frame MapHtml::snapshot() initSnapshots(), relayout()
data-src, data-signature, data-tilesize, data-xtiles, data-ytiles, width, height picture frame MapHtml::snapshot() layout()
data-missing picture frame MapHtml::snapshot() snapshot.js: the picture shown for a missing picture or tile
data-omnimap-initialized picture frame snapshot.js snapshot.js
data-background-image a tile's span layout() removed when the tile loads
data-omnimap-export, data-omnimap-suppress, data-omnimap-target, data-omnimap-token button MapHtml::button() bindCapture()
data-omnimap-bound button bindCapture() bindCapture()
Event Target When
omnimap:ready the map's element (bubbles), detail: {map, description} a driver drew the map
idle window Google's idle (each time), Leaflet's map ready, MapLibre's first idle
tilesloaded, drag window Google only, each time the map fires them
load, resize, transparent:ready, transparent:postactive window listened to by autoInit()

Proposer une modification docs/javascript.md

Configuration reference

Every option below is read from the code at this commit: the factories' populate() defaults and what their build() reads, and the constructors of the core's classes. Options reach a factory through create($options), a Registry's options, or the bridges' providers key (Symfony, Laravel). A value may be given as an environment variable gives it: Config::bool() reads 1, true, yes, on as true, Config::int() and float() read numeric strings, and an empty string is "not set" for a string option.

The registry

new Registry(iterable $factories, array $config);
Argument
$factories ProviderFactoryInterfaces, keyed by their getName() (a later one of the same name replaces an earlier one)
$config array<string, array{factory: string, options?: array}>: each provider's name, its factory's name, its options

omnimap/google

new GoogleFactory(?HttpClientInterface $http = null, ?Psr\Http\Client\ClientInterface $psr18 = null, ?Psr\SimpleCache\CacheInterface $cache = null); no required option.

Option Type Default
api_key string '' the browser key (Maps JavaScript API), written in the page: restrict it to your sites in Google's console
server_key string '' the key of the server's calls (Static Maps, Geocoding); the api_key when empty
secret string '' the URL signing secret: Static Maps' signature (none when empty)
version string weekly the Maps JavaScript API's channel or version: weekly, quarterly, beta, a number
language string '' the map's language and the geocoder's default locale
region string '' the map's region, and geocoder-php's region bias
legacy_signature bool false true: a view's fingerprint is glitchr/ux-google's, so the pictures it captured keep their names
geocoder_ttl int 2592000 seconds a geocoding answer is kept (30 days)

Static Maps needs the HTTP client; geocoding needs the PSR-18 client and a key (server_key or api_key); both answers are kept in $cache when given (geocoding only: Static Maps images are not cached by the provider; the map cache keeps the picture).

omnimap/openstreetmap

new OpenStreetMapFactory(?HttpClientInterface $http = null, ?Psr\Http\Client\ClientInterface $psr18 = null, ?Psr\SimpleCache\CacheInterface $cache = null); no required option.

Option Type Default
tiles string https://tile.openstreetmap.org/{z}/{x}/{y}.png the tile URL, {z}, {x}, {y} in it, {s} for a server's subdomains (a, b, c); the default is the Foundation's server, for modest use
attribution string (HTML) &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors shown on the live map, beside a kept picture and drawn on an image
max_zoom int 19
tile_size int 256 the tiles' size in pixels; 512 for a server numbering 512-pixel tiles 2^z a side at z (Tiles)
user_agent string '' the application's name and a contact, sent with every request the server makes (tiles, Nominatim); required to fetch a tile and to geocode
referer string '' sent with them too, when set
nominatim string https://nominatim.openstreetmap.org the geocoder's URL; empty: no geocoding
nominatim_interval float 1.0 seconds between two geocoder calls of a process
geocoder_ttl int 2592000 seconds a geocoding answer is kept
leaflet_script string https://unpkg.com/leaflet@1.9.4/dist/leaflet.js where the browser gets Leaflet
leaflet_script_integrity string sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo= its Subresource Integrity hash; empty: none
leaflet_style string https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
leaflet_style_integrity string sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=
background string #aad3df the colour above and below the world on an image made of tiles

The tile host and the Leaflet script's host are the provider's browserHosts.

omnimap/maplibre

new MapLibreFactory(?HttpClientInterface $http = null); no required option. The client is only for MapLibreProvider::inspect() (reading a style's hosts on the server).

Option Type Default
style string https://tiles.openfreemap.org/styles/liberty the MapLibre style's URL
attribution string (HTML) OpenFreeMap, OpenMapTiles, OpenStreetMap (MapLibreFactory::OPENFREEMAP_ATTRIBUTION) shown beside a kept picture; the live map shows its sources' own
hosts list or comma-separated string [] the hosts the browser reaches for the style, its tiles, glyphs and sprite; the style's host when empty
library_script string https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js
library_script_integrity string MapLibreFactory::LIBRARY_SCRIPT_INTEGRITY (SHA-384)
library_style string https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css
library_style_integrity string MapLibreFactory::LIBRARY_STYLE_INTEGRITY (SHA-384)
library_module string '' an ES module instead of the script (MapLibre GL JS 6); set, the script and its integrity are left out
preserve_drawing_buffer bool true keeps the canvas readable for capture.js

Map cache classes

Class Constructor
Signer (?string $secret) URL-safe base64; null or '' signs with an empty key
Snapshot\SnapshotStore (Storage $storage, string $directory = 'omnimap', string $pattern = '{signature}/image-{id}', string $format = 'png') The layout
Snapshot\Snapshots (MapRendererInterface $renderer, Signer $signer, SnapshotStore $store, ?StaticMapInterface $staticMap = null, Size $size = new Size(1512, 938), int $scale = 1, ?int $tilesize = 512) size, scale: a picture the server makes; tilesize: how pictures are cut (null: whole)
Snapshot\DirectoryStorage (string $root)
Snapshot\FlysystemStorage (League\Flysystem\FilesystemOperator $filesystem)
Snapshot\DriveStorage (Omnidrive\Drive $drive)
MapHtml (array $options = []) class (omnimap-snapshot), suffix (_snapshot), src (/omnimap/{signature}/{id}.webp), missing ('': the address of the picture snapshot.js shows for a missing picture or tile)
MapPrinter (Snapshots $snapshots, MapHtml $html = new MapHtml(), bool $cacheOnly = true, ?\Closure $onError = null) The page

Images and geocoding

Class Constructor
Static\TileStitcher (TileSourceInterface $tiles, HttpClientInterface $http, ?CacheInterface $cache = null, string $background = '#aad3df', string $markerColor = '#d33b3b') colours as CSS hex (#rgb or #rrggbb)
Geocoding\GeocoderPhp (Geocoder\Provider\Provider $provider, ?CacheInterface $cache = null, int $ttl = 2592000, float $interval = 0.0) ttl in seconds, interval the pace in seconds

Browser options

The options of mountAll()/draw() (consent), bindCapture() (html2canvas, format, quality, tilesize, html2canvasOptions) and the description's keys are in The browser.

Proposer une modification docs/configuration.md

Errors

Every exception of the family implements Omnimap\Exception\OmnimapException (an interface extending \Throwable), so one catch (OmnimapException $e) takes them all, and each also extends the SPL exception that fits, for code that catches those:

Exception Extends Thrown when
InvalidConfigException \InvalidArgumentException a provider is not configured or its factory not installed (Registry); a required option is missing (ProviderFactory::create()); a call needs an option that was not given: Google's staticUrl() without a key, an OpenStreetMap tile's headers() without a user_agent
NotSupportedException \LogicException a provider asked for a contract it does not implement (Registry::renderer(), staticMap(), geocoder(), tiles()); a capability declared off, called anyway (no HTTP client, no key for Google or no user_agent for OpenStreetMap for an image, no PSR-18 client or key for geocoding, no client for MapLibre's inspect()); Snapshots::make() with no static map (check canMake() first, or call findOrMake()); cache.make: true in Symfony on a provider that makes no image
ProviderException \RuntimeException the provider answered, and not with what was asked: a status other than 200 (401, 403, 404, 429, 5xx), a body that is not an image where an image was asked (a proxy's HTML page in a 200), geocoder-php's own errors (an error status, a quota, an answer it could not read), a MapLibre style that is not JSON or not a style
UnreachableException \RuntimeException the provider could not be reached: DNS, connection, timeout (Symfony's HTTP client's transport exceptions, PSR-18's NetworkExceptionInterface); a copy of the client's exception is the previous one (no secret in it)
InvalidArgumentException \InvalidArgumentException a value no map can hold: a latitude past a pole or not finite, a zoom outside 0..24, a size below one pixel or not WIDTHxHEIGHT; a signature that is not [A-Za-z0-9_-]{1,128}; a picture GD cannot read; a cache format other than png, jpeg, jpg, webp; a DirectoryStorage path holding .. (SnapshotStore never builds one: signatures are checked first)

The messages name the provider (its title) and what was asked:

<?php

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

use Omnimap\Exception\OmnimapException;
use Omnimap\MapLibre\MapLibreFactory;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use Omnimap\Registry;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$down = new MockHttpClient(static fn () => new MockResponse('Unavailable', ['http_code' => 503]));
$cut = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Could not resolve host']));
$registry = new Registry([new OpenStreetMapFactory($down), new MapLibreFactory()], [
    'osm' => ['factory' => 'openstreetmap', 'options' => ['user_agent' => 'MySite/1.0']],
    'vector' => ['factory' => 'maplibre'],
    'other' => ['factory' => 'google'],
]);
$view = new MapView(new LatLng(35.0116, 135.7681), 12);

$attempts = [
    'unknown name' => fn () => $registry->get('nope'),
    'factory not installed' => fn () => $registry->get('other'),
    'capability missing' => fn () => $registry->staticMap('vector'),
    'provider answered 503' => fn () => $registry->staticMap('osm')->image($view, new Size(256, 256)),
    'network error' => fn () => (new OpenStreetMapFactory($cut))->create(['user_agent' => 'MySite/1.0'])->image($view, new Size(256, 256)),
    'geocoder switched off' => fn () => $registry->get('osm')->geocode('Kyoto'),
    'latitude past a pole' => fn () => new LatLng(91, 0),
    'zoom too deep' => fn () => new MapView(new LatLng(0, 0), 25),
];
foreach ($attempts as $label => $attempt) {
    try {
        $attempt();
    } catch (OmnimapException $e) {
        printf("%-22s %s: %s\n", $label, (new ReflectionClass($e))->getShortName(), $e->getMessage());
    }
}
unknown name           InvalidConfigException: No "nope" map provider; configured: osm, vector, other.
factory not installed  InvalidConfigException: No "google" factory for the "other" map provider; installed: openstreetmap, maplibre.
capability missing     NotSupportedException: The "vector" provider (Omnimap\MapLibre\MapLibreProvider) does not make a map image.
provider answered 503  ProviderException: OpenStreetMap answered 503 for https://tile.openstreetmap.org/12/3592/1621.png.
network error          UnreachableException: OpenStreetMap could not be reached for https://tile.openstreetmap.org/12/3592/1621.png: Could not resolve host
geocoder switched off  NotSupportedException: No PSR-18 client, no user_agent or no nominatim URL was given to the OpenStreetMap provider: no geocoding.
latitude past a pole   InvalidArgumentException: A latitude lies between -90 and 90, not 91.
zoom too deep          InvalidArgumentException: A zoom lies between 0 and 24, not 25.

(run on 2026-10-25 in the harness)

No secret in a failure

What omnimap throws keeps a key out. An address in a message (a tile's, a style's, a Static Maps image's, the one geocoder-php quotes) has its key parameters (key=, api_key=, signature=) and a password in its user part masked (key=***, Http\Redaction::text()), and so does the HTTP client's message after it, with every secret of the request (a tile source's headers() token, a PSR-18 request's headers). The previous exception of an UnreachableException - and of a geocoder's ProviderException - is a copy of the client's (Redaction::copy(): the same class and code, that message, without the request a PSR-18 exception carried), and no trace of the chain keeps its arguments, where the URL and headers stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key.

What to catch

  • Around a page's map: nothing. MapPrinter::map() catches whatever making a picture throws, hands it to its $onError and shows no picture (the bridges log it as a warning).
  • Around a server-made image or a geocoding call: ProviderException and UnreachableException (the service failed or refused: retry later, show the address without a map) apart from InvalidConfigException and NotSupportedException (the application's configuration: fix it).
  • The conformance suite holds every provider to this rule: for each operation that calls the service, statuses 401, 403, 404, 429, 500, 503 and an HTML page in a 200 are ProviderExceptions, a transport failure an UnreachableException (Testing).
  • A 429's Retry-After is not read: ProviderException carries no retry delay.

Outside the family

What a storage or a client throws itself passes through as it is:

Where Exception When
FlysystemStorage, DriveStorage Flysystem's FilesystemException, omnidrive's exceptions the storage failed to write or delete (a missing file on read is null, not an exception)

Proposer une modification docs/errors.md

Testing

Testing an application that shows maps with omnimap, and the conformance suite every provider runs. Everything in Omnimap\Testing ships with the core (it is autoloaded with it), so a test needs no network and no other package; ProviderContractTests needs phpunit/phpunit and symfony/http-client, the image helpers GD.

The fakes

Class
TestTiles(HttpClientInterface $http, ?CacheInterface $cache = null) a provider (name test, title Test tiles): a renderer, a static map (through TileStitcher) and a tile source on https://tiles.example/{z}/{x}/{y}.png, browser host tiles.example, attribution &copy; Test tiles, User-Agent omnimap-tests/1.0 (+https://github.com/glitchr-omnisense/omnimap); no factory
PlainTiles::png(int $zoom, int $x, int $y, int $size = 256): string a PNG tile of one colour per zoom, x and y, its coordinates written on it
PlainTiles::answer(): \Closure the answer RecordedHttpClient gives for any URL ending in /{z}/{x}/{y}.png: that tile, with Cache-Control: max-age=91080, stale-while-revalidate=604800, stale-if-error=604800; a 404 otherwise
RecordedHttpClient(\Closure $answer) a Symfony HTTP client answering from a closure of the method, the URL and the options, returning [status, body, headers]; every request kept in $requests (method, url, options); stream() is not supported
RecordedResponse its responses: getStatusCode(), getHeaders() (names lowercased), getContent(), toArray() ([] when the body is not a JSON object), never throwing
ArrayCache a PSR-16 cache in memory; $ttls keeps the lifetime each key was set with (a \DateInterval read as seconds)
FixedGeocoder(array $places) a geocoder-php provider (name fixed) that knows places by their exact text: ['Kyoto' => ['lat' => ..., 'lng' => ..., 'locality' => ..., 'country' => ..., 'countryCode' => ...]]; reverse finds a place within 0.01 degree; $calls counts the questions

Symfony's MockHttpClient and MockResponse work as well wherever a provider takes an HTTP client (status codes, transport errors: new MockResponse('', ['error' => '...'])).

An application's test

<?php

use Omnimap\Geocoding\GeocoderPhp;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\OpenStreetMap\OpenStreetMapProvider;
use Omnimap\Signer;
use Omnimap\Snapshot\DirectoryStorage;
use Omnimap\Snapshot\Snapshots;
use Omnimap\Snapshot\SnapshotStore;
use Omnimap\Testing\ArrayCache;
use Omnimap\Testing\FixedGeocoder;
use Omnimap\Testing\PlainTiles;
use Omnimap\Testing\RecordedHttpClient;
use Omnimap\Testing\TestTiles;
use PHPUnit\Framework\TestCase;

final class StoreMapTest extends TestCase
{
    public function testTheStoresMapIsMadeOnceAndKept(): void
    {
        $http = new RecordedHttpClient(PlainTiles::answer());          // every tile drawn here, no network
        $tiles = new TestTiles($http, new ArrayCache());
        $snapshots = new Snapshots($tiles, new Signer('c2VjcmV0'), new SnapshotStore(new DirectoryStorage(sys_get_temp_dir().'/maps-test')), $tiles);
        $view = new MapView(new LatLng(48.5818, 7.7509), 14, id: 'store');

        $snapshot = $snapshots->findOrMake($view);
        self::assertSame([1512, 938, 3, 2], [$snapshot->width, $snapshot->height, $snapshot->xtiles, $snapshot->ytiles]);
        self::assertCount(35, $http->requests);

        self::assertEquals($snapshot, $snapshots->findOrMake($view));    // read back, not made again
        self::assertCount(35, $http->requests);
        $snapshots->delete($snapshot->signature);
    }

    public function testTheStoresAddressIsGeocoded(): void
    {
        $places = new FixedGeocoder(['1 place de la Cathédrale, Strasbourg' => ['lat' => 48.5818, 'lng' => 7.7509, 'locality' => 'Strasbourg', 'country' => 'France', 'countryCode' => 'FR']]);
        $geocoder = new OpenStreetMapProvider(geocoder: new GeocoderPhp($places, new ArrayCache()));

        $found = $geocoder->geocode('1 place de la Cathédrale, Strasbourg');
        self::assertSame('Strasbourg, France', $found[0]->label);
        self::assertSame('fixed', $found[0]->providedBy);
        self::assertSame([], $geocoder->geocode('nowhere'));
        $geocoder->geocode('1 place de la Cathédrale, Strasbourg');
        self::assertSame(2, $places->calls);                             // the repeated question answered by the cache
    }
}
$ vendor/bin/phpunit --testdox tests/
Store Map
 ✔ The stores map is made once and kept
 ✔ The stores address is geocoded

Tile Server Down
 ✔ A tile server down is a provider exception

OK (3 tests, 9 assertions)

(run on 2026-10-25 in the harness, PHPUnit 11.5, with TileServerDownTest below)

A geocoder that knows its answers

GeocoderPhp is not itself a GeocoderInterface; to hand application code a geocoder that answers from a list, build OpenStreetMapProvider directly with a GeocoderPhp over FixedGeocoder, as the second test does: capabilities()->geocoder is true, and nothing else of the provider is used.

A provider on recorded answers

To test the application's handling of a real provider's answers and failures, give its factory a MockHttpClient (and a Psr18Client over it for geocoding), as the third test of the run above:

<?php

use Omnimap\Exception\ProviderException;
use Omnimap\Model\LatLng;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\OpenStreetMap\OpenStreetMapFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class TileServerDownTest extends TestCase
{
    public function testATileServerDownIsAProviderException(): void
    {
        $http = new MockHttpClient(static fn () => new MockResponse('Unavailable', ['http_code' => 503]));
        $osm = (new OpenStreetMapFactory($http))->create(['user_agent' => 'tests/1.0']);

        $this->expectException(ProviderException::class);
        $osm->image(new MapView(new LatLng(35.0116, 135.7681), 12), new Size(256, 256));
    }
}

A closure makes one response per request: an image needs several tiles. Given a single MockResponse object instead, the same call ends in Symfony's own ServerException (seen in the harness), not in the family's ProviderException.

In a Symfony or Laravel test

Replace the HTTP client the bridge gives the factories: http_client in Symfony's test environment, or bind omnimap.http_client in Laravel, with a MockHttpClient answering PlainTiles::png(); point the cache's storage at a temporary directory. omnibridge/symfony's and omnibridge/laravel's Tests/MapTest.php do exactly that (Symfony, Laravel).

Conformance suite: each test

Omnimap\Testing\ProviderContractTests is a PHPUnit trait: a provider's test case uses it and gives its factory, built over the HTTP client the suite hands it. No test reaches the network: every answer is a MockResponse. The core runs it on TestTiles (Tests/TestTilesContractTest.php); each provider in Tests/<Name>ContractTest.php. Writing one: Writing a provider.

The hooks:

Hook Default
contractFactory(?HttpClientInterface $http): ProviderFactoryInterface abstract the factory over $http; null builds a provider without a client, its network capabilities off. A geocoding provider wraps $http in a Psr18Client
contractOptions(): array [] options that switch every capability on (fake keys, a User-Agent, nominatim_interval: 0)
contractAnswer(string $method, string $url, array $options): array [200, PlainTiles::png(1, 0, 0), content-type image/png, max-age 3600] for any request [status, body, headers] of a request that succeeds: a geocoder answers its service's recorded answer for its geocoding URLs
contractGeocodeQuery(): string Osaka, Japan a free-text question the recorded geocoding answer answers

The view every test uses: Osaka (34.6937, 135.5023) at zoom 11, one marker at Umeda, id contract.

Test What it checks
testTheFactoryNamesWhatItBuildsAndTheRegistryBuildsItOnce the factory's name is the provider's, matches ^[a-z0-9][a-z0-9_-]*$; a title that is not blank; a Registry builds it from {factory, options}, the same object twice
testAMissingRequiredOptionIsRefusedByName each option in omnimap.required_options, removed, is an InvalidConfigException whose message names it; with none, create([]) builds a provider
testCapabilitiesAreWhatTheProviderAnswers with and without a client: at least one contract; each declared contract is implemented; browser hosts are host names (no scheme, no path); terms start with https://; toArray() survives JSON. Without a client: a static map or geocoder implemented but declared off throws NotSupportedException (never a TypeError)
testARendererDescribesTheViewAsPlainData provider is the provider's name, consent a string, view is MapView::toArray(), attribution a string equal to attribution(); JSON and back gives the same; every http(s) address in it (but the attribution's links) is on a declared browser host ({s} read as a); describing sends no request; the fingerprint is stable and changes with the centre, the zoom and the markers
testAStaticMapIsAnImageOfTheSizeAsked a 300 x 200 image: bytes that are an image of the media type it says, no larger than asked, fetched over https:// only
testTileAddressesAreComplete the template holds {z}, {x}, {y}; url(11, 1798, 812) is https://, has no {, holds 1798; tile size 256 or 512; max zoom 1 to 24; headers() answers with the contract's options
testAGeocoderAnswersPlacesOfItsOwn geocode(contractGeocodeQuery(), 'en', 1): at least one place, at most one, providedBy not empty, coordinates on the globe; reverse() of it keeps the limit
testErrorsAreTheFamilysExceptions for image() and geocode() when declared: answers 401, 403, 404, 429 (with Retry-After), 500, 503, an HTML page in a 200 are each a ProviderException, a transport failure (Could not resolve host) an UnreachableException; never an answer, never another exception. A provider that fetches nothing on the server has nothing to check

A check that does not apply (a renderer-only provider's image test) passes by asserting the capability is off: such a test is not skipped, and the provider is counted as running the suite.

Proposer une modification docs/testing.md

The Docker harness

docker/ composes this core and every provider (docker/harness/plugins.php) into one installation, PHP 8.4 with GD:

docker compose run --rm omnimap test       # every package's tests, no network
docker compose run --rm omnimap live       # the checks that reach OpenStreetMap, within its policies
docker compose run --rm omnimap bare       # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omnimap phpstan    # PHPStan at the highest level, each package
docker compose run --rm js                 # the browser scripts' tests, on node's test runner
docker compose down -v

Providers come from GitHub (branch 1.x) unless OMNIMAP_PLUGINS (in docker/.env) points at a directory holding their checkouts - ../.. when this core sits in the workspace beside them.

What it is made of

File
docker/Dockerfile php:8.4-cli with GD (PNG, JPEG, WebP, FreeType), git, unzip and Composer 2; image omnisense/omnimap-harness
docker/compose.yml the service omnimap, on Docker's default bridge (no network of its own); mounts the core at /omnimap/core, the providers' checkouts read-only at /omnimap/plugins, and a volume harness at /harness (the composed installation and Composer's cache). The service js (node:22-alpine, no network) runs node --test over Tests/js/*.test.mjs of the core and of each provider's checkout, the core and the providers mounted read-only
docker/.env OMNIMAP_PLUGINS=../..: the checkouts beside the core
docker/harness/plugins.php the providers installed beside the core and their tests' namespaces: google, openstreetmap, maplibre
docker/harness/setup.php writes /harness/composer.json and /harness/phpunit.xml: the core and each provider as path repositories (symlinked) when their checkout is there, else a git clone of https://github.com/glitchr-omnisense/omnimap-<name>.git; the dev tools (PHPUnit 11, PHPStan 2, symfony/http-client and cache, nyholm/psr7, Flysystem and its memory adapter, omnidrive, opis/json-schema); one test suite per package; /harness/packages/<name> linked to each provider for PHPStan. Files are rewritten only when they change
docker/entrypoint.sh runs setup.php, then composer install (no vendor/) or composer update (composer.json newer than the lock), then the command
docker/harness/bin/bare the plain-PHP script Tests/BareTest.php runs

Commands

Command
test [phpunit arguments] PHPUnit over every package, the group live excluded; arguments go to PHPUnit (test --filter Signer, test --testdox /scratch/tests)
live [phpunit arguments] the group live only: an image of Kyoto from tile.openstreetmap.org and one Nominatim query (omnimap/openstreetmap), OpenFreeMap's style, tiles, glyphs and sprite and unpkg's MapLibre hashes (omnimap/maplibre)
bare docker/harness/bin/bare: a view of Kyoto with two markers described, its picture made of tiles answered by PlainTiles, signed, cut into a directory, read back and printed, made again from cached tiles, deleted, Kyoto geocoded twice; prints what happened and every class and file PHP loaded, as JSON
phpstan PHPStan on the core and on each package that has a phpstan.neon, at its baseline
composer ..., php ..., sh as they are, in /harness

Running a script of one's own

The examples of this manual were run this way: put the script in a directory, mount it, and require the harness's autoloader.

cd docker
docker compose run --rm -v "$PWD/../scratch:/scratch" omnimap php /scratch/example.php
<?php

require '/harness/vendor/autoload.php';   // in the harness; __DIR__.'/vendor/autoload.php' in an application

--rm removes the container after the run; the harness volume stays (the installation is reused by the next run) until docker compose down -v.

Run on 2026-10-25: test 92 tests, 568 assertions, green (PHP 8.4.26, PHPUnit 11.5.57).

Proposer une modification docs/harness.md

Writing a provider

A provider is a package omnimap/<name> with a factory and a provider class:

  • the factory extends Omnimap\ProviderFactory: populate() names it, lists its required options and their defaults; build() returns the provider. Clients (HTTP, PSR-18) and caches come through the factory's constructor, never created inside.
  • the provider implements the contracts it can answer - MapRendererInterface, StaticMapInterface, GeocoderInterface, TileSourceInterface - and says so in capabilities(), with the hosts a visitor's browser reaches and the terms that apply.
  • geocoding goes through Geocoding\GeocoderPhp over a geocoder-php provider: omnimap does not parse a geocoding API itself.
  • a renderer's describe() is plain data; its driver (assets/<name>.js) is an ES module exporting draw(element, description, options).
  • fingerprint() is everything the picture of a view depends on: two views with the same fingerprint must look the same.
  • tests run in the core's harness (docker/harness/plugins.php lists the providers), with a BareTest loading every class with the package's requirements alone.
  • the bridges list the factories they build by class (omnibridge/symfony's OmnimapExtension, omnibridge/laravel's MapServiceProvider::FACTORIES): a new provider is wired there too, or an application registers its factory itself (Symfony: a service tagged omnimap.provider_factory).

The package layout (omnimap/openstreetmap):

openstreetmap/
  OpenStreetMapFactory.php      # name, title, options and their defaults, the provider built
  OpenStreetMapProvider.php     # the contracts it answers, its capabilities, its description
  assets/leaflet.js             # the browser driver: draw(element, description, options)
  Tests/OpenStreetMapContractTest.php   # the conformance suite
  Tests/OpenStreetMapProviderTest.php   # what is particular to the provider
  Tests/BareTest.php            # every class loads with the package's requirements alone
  Tests/LiveTest.php            # #[Group('live')]: the real service, excluded by default

A whole provider

A raster tile service with a key: a renderer, a static map made of its tiles, a tile source. This code, with the contract test after it, ran green in the harness on 2026-10-25 (the suite's 8 tests).

<?php

use Omnimap\Capabilities;
use Omnimap\Config;
use Omnimap\Exception\NotSupportedException;
use Omnimap\MapRendererInterface;
use Omnimap\Model\Image;
use Omnimap\Model\MapView;
use Omnimap\Model\Size;
use Omnimap\ProviderFactory;
use Omnimap\ProviderInterface;
use Omnimap\Static\TileStitcher;
use Omnimap\StaticMapInterface;
use Omnimap\TileSourceInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnimap.factory_name' => 'acme',
            'omnimap.factory_title' => 'Acme Maps',
            'omnimap.required_options' => ['api_key'],
            'max_zoom' => 18,
        ]);
    }

    protected function build(Config $c): ProviderInterface
    {
        return new AcmeProvider((string) $c->string('api_key'), $c->int('max_zoom', 18), $this->http);
    }
}

final class AcmeProvider implements MapRendererInterface, StaticMapInterface, TileSourceInterface
{
    public function __construct(private readonly string $key, private readonly int $maxZoom, private readonly ?HttpClientInterface $http)
    {
    }

    public function getName(): string { return 'acme'; }
    public function getTitle(): string { return 'Acme Maps'; }

    public function capabilities(): Capabilities
    {
        return new Capabilities(renderer: true, staticMap: null !== $this->http, tiles: true, browserHosts: ['tiles.acme.example'], terms: ['https://acme.example/terms']);
    }

    public function describe(MapView $view): array
    {
        return ['provider' => 'acme', 'consent' => 'MAP', 'view' => $view->toArray(), 'tiles' => ['template' => $this->template()], 'attribution' => $this->attribution()];
    }

    public function fingerprint(MapView $view): string
    {
        return 'acme|'.json_encode([$view->center, $view->zoom, $view->markers, $view->controls], \JSON_THROW_ON_ERROR);
    }

    public function attribution(): string { return '&copy; Acme'; }

    public function image(MapView $view, Size $size, int $scale = 1): Image
    {
        $http = $this->http ?? throw new NotSupportedException('No HTTP client was given to Acme: no image.');

        return (new TileStitcher($this, $http))->image($view, $size, $scale);
    }

    public function template(): string { return 'https://tiles.acme.example/{z}/{x}/{y}.png?key='.rawurlencode($this->key); }
    public function url(int $zoom, int $x, int $y): string { return str_replace(['{z}', '{x}', '{y}'], [(string) $zoom, (string) $x, (string) $y], $this->template()); }
    public function tileSize(): int { return 256; }
    public function maxZoom(): int { return $this->maxZoom; }
    public function headers(): array { return ['User-Agent' => 'acme-tests/1.0']; }
}

The key is in the tile URL the browser reaches: a service whose keys must stay secret is drawn from the server's images only (no renderer), or through a proxy route of the host's.

The conformance suite

Omnimap\Testing\ProviderContractTests is what every provider is held to, as PHPUnit tests, offline: the provider's test case uses the trait and gives its factory built over the HTTP client the suite hands it (recorded answers, no network).

final class AcmeContractTest extends TestCase
{
    use ProviderContractTests;

    protected function contractFactory(?HttpClientInterface $http): ProviderFactoryInterface
    {
        return new AcmeFactory($http);
    }

    protected function contractOptions(): array
    {
        return ['api_key' => 'test-key'];
    }
}
Acme Contract
 ✔ The factory names what it builds and the registry builds it once
 ✔ A missing required option is refused by name
 ✔ Capabilities are what the provider answers
 ✔ A renderer describes the view as plain data
 ✔ A static map is an image of the size asked
 ✔ Tile addresses are complete
 ✔ A geocoder answers places of its own
 ✔ Errors are the familys exceptions

A provider that geocodes gives its factory a PSR-18 client over the suite's HTTP client, and its recorded geocoding answer:

final class MyMapContractTest extends TestCase
{
    use ProviderContractTests;

    protected function contractFactory(?HttpClientInterface $http): ProviderFactoryInterface
    {
        return new MyMapFactory($http, null === $http ? null : new Psr18Client($http), new ArrayCache());
    }

    protected function contractOptions(): array { return ['api_key' => 'test-key']; }   // every capability on

    // Optional: the recorded answer of a request that succeeds (default: a PNG tile for any URL).
    protected function contractAnswer(string $method, string $url, array $options): array { ... }
}

It checks the factory and the Registry (the same name, built once, a missing required option refused by name), that capabilities() is what the provider answers (a capability declared is implemented; one implemented but off, without its client, throws NotSupportedException), a renderer's description (plain data, the provider's name for its driver, the view, the consent, every address in it on a declared browser host, a fingerprint that follows centre, zoom and markers), a static map's image (the bytes are the image type it says, no larger than asked), a tile source's addresses, a geocoder's places, and the family's rule for errors:

  • an answer other than 200, or a body that is not what was asked (a proxy's HTML page in a 200, JSON of another shape), is a ProviderException;
  • a request that never got an answer (DNS, connection, timeout) is an UnreachableException;
  • a capability the provider does not have, or has switched off, is a NotSupportedException.

The provider's require-dev holds phpunit/phpunit and symfony/http-client. The core runs the suite on its own Testing\TestTiles (Tests/TestTilesContractTest.php). Each test and each hook: Testing.

Checklist

  • the factory names the provider (omnimap.factory_name, the configuration's factory), its title, its required options and every default; building calls nothing;
  • capabilities() is what the provider answers, built without a client too;
  • describe() holds to schema/map-description.schema.json (add the factory to Tests/MapDescriptionSchemaTest.php's list in the core) and names every host the browser reaches in browserHosts;
  • the driver assets/<name>.js exports { draw } and uses the loadScript()/loadStyle() it is given;
  • errors are ProviderException / UnreachableException, a capability off NotSupportedException;
  • the service's terms are in terms, its rate limits and its required User-Agent kept in code;
  • docs/index.md: options, capabilities, behaviour, errors, verified and not (CONTRIBUTING.md of omnimap/maplibre is the family's guide to testing, documenting and releasing a provider).

Proposer une modification docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-25 in the harness, the core and its three providers from their checkouts, PHP 8.4.26 with GD, PHPUnit 11.5.57: 92 tests, 568 assertions, green (the live group excluded). The core's own tests:

  • the models and the projection: a latitude past a pole refused, a longitude wrapped, a box across the antimeridian, the slippy-map tile of Kyoto at zoom 12, the world's corners, a point there and back, the box of a view (ModelTest, WebMercatorTest);
  • the registry and the configuration: providers by name built once, a required option, values as an environment gives them (RegistryTest);
  • the signer against hash_hmac() and against geocoder-php's own implementation of Google's URL signing, an empty secret (SignerTest);
  • the cache: a capture glitchr/ux-google wrote read as it is (its metadata.txt byte for byte, written back identical); cutting, padding with transparency, deleting, in a directory, in Flysystem's memory adapter and on an omnidrive Drive; a whole picture; only a signature reaching a path; not an image refused (SnapshotStoreTest); the cache the same whichever makes the picture (SnapshotsTest);
  • the page: the picture, the live map or an empty frame by MapPrinter's rule, a picture the server could not make (MapPrinterTest); the markup with a host's own names, no picture yet, the live map and the buttons (MapHtmlTest);
  • images made of tiles: each tile once with the source's headers then from the cache, kept as long as max-age plus stale-while-revalidate, twice the density from the next zoom, the world repeating across the antimeridian, the markers drawn, an error status (TileStitcherTest);
  • the geocoder adapter: a place found then kept, the pace between two calls (GeocoderPhpTest);
  • every installed renderer's description against schema/map-description.schema.json (opis/json-schema), and what is not a description refused (MapDescriptionSchemaTest);
  • in plain PHP, a view described, made, cached, printed and geocoded loading nothing but omnimap and what it requires, and every class loading with the required packages alone (BareTest);
  • Installation's script, run as it is written (DocsTest);
  • the conformance suite on TestTiles (TestTilesContractTest), on omnimap/google, omnimap/openstreetmap and omnimap/maplibre (Testing).

The capability table of the index was probed with capabilities() on 2026-10-25. Every example of this manual marked "run" was run in the harness on 2026-10-25 and its output pasted as it came.

Verified live

  • docker compose run --rm omnimap live, 2026-10-23: an image of Kyoto made of tile.openstreetmap.org's tiles and one Nominatim query, with omnimap/openstreetmap's User-Agent.
  • omnimap/maplibre's live group, 2026-10-25 (its README): OpenFreeMap's style, its TileJSON, a vector tile, a glyph range and the sprite on the declared host; unpkg's MapLibre GL JS 5.24.0 against its SHA-384 hashes; assets/maplibre.js drawn through omnimap.js in headless Chrome.
  • On lesringostars, 2026-10-23 (the providers' READMEs): the names of the pictures ux-google captured kept, their tiles served at the same addresses; with MAP_PROVIDER=openstreetmap, the page's map made by the server and kept, no request to Google nor to the tile server from the visitor's browser (headless Chrome).

What is not verified

  • The core's JavaScript in a browser: snapshot.js's layout and data-missing, and leaflet.js's tile layer, are tested on node's test runner over a stand-in DOM and Leaflet (docker compose run --rm js); omnimap.js, capture.js and the Google driver have no test of their own; only maplibre.js was drawn in a browser.
  • Google's live answers (Static Maps, Geocoding, the Maps JavaScript API): no key in the harness.
  • Tile servers other than the OpenStreetMap Foundation's; 512-pixel tile sources are tested on tiles drawn by the tests (the numbering of Leaflet's zoomOffset: -1), not against a real 512-pixel server.
  • The Symfony and Laravel pages of this manual were written from omnibridge's code and its MapTests, not run in an application for this manual.
  • The scripts that reach a real service (the second script of Installation, the Photon example of Geocoding) were not run.

Responsibility

omnisense/omnimap and its omnimap/* 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 its data; whoever runs them complies with each service's terms (named in each provider's README and in Capabilities::$terms) and obtains what they require - including whether a picture of a provider's map may be kept in the map cache.

Proposer une modification docs/verified.md