thinwrap / location
Lightweight, SDK-free PHP wrapper for routing, distance matrix, geocoding, and isochrone across Google, Mapbox, HERE, ESRI, TomTom, and OSRM. Stateless, PSR-18 BYO HTTP client; php-http/discovery is the only runtime dependency (auto-wires a PSR-18 client when none is injected); no vendor SDKs.
Requires
- php: ^8.2
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.10
- guzzlehttp/psr7: ^2.9
- phpstan/phpstan: ^2.1.54
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Unified PHP facade for 21 location connectors across routing, matrix, geocoding, and isochrone — over 6 providers (Google, Mapbox, HERE, ESRI, TomTom, OSRM). Stateless. Zero vendor SDKs. Bring your own PSR-18 HTTP client.
Install
composer require thinwrap/location
Requires PHP ≥8.2. PSR-18 HTTP client + PSR-17 factories are auto-discovered via
php-http/discovery — if you don't already have one installed:
composer require guzzlehttp/guzzle guzzlehttp/psr7
End-to-end example — 2-minute time-to-first-route
use Thinwrap\Location\Enum\LocationProviderId; use Thinwrap\Location\Config\GoogleConfig; use Thinwrap\Location\DTO\LatLng; use Thinwrap\Location\DTO\Routing\RoutingOptions; use Thinwrap\Location\Routing; use Thinwrap\Location\ConnectorError; $routing = new Routing(LocationProviderId::Google, new GoogleConfig(apiKey: getenv('GOOGLE_KEY'))); try { $result = $routing->route(new RoutingOptions( waypoints: [ new LatLng(40.7128, -74.0060), // New York new LatLng(41.4173, -73.0001), // Bridgeport ], travelMode: 'driving', )); echo $result->totalDistanceMeters; // distance in meters echo $result->totalDurationSeconds; // duration in seconds echo $result->polyline; // Google precision-5 polyline string } catch (ConnectorError $e) { error_log($e->providerCode->value . ': ' . ($e->providerMessage ?? '')); }
Routing options that cost money
Three inputs exist because the cheap thing and the correct thing are not always the same request. All three default to the lean option; you opt up explicitly.
| Option | Default | What it changes |
|---|---|---|
$polylineQuality |
PolylineQuality::Simplified |
Geometry fidelity. Detailed returned a 30x larger polyline on Mapbox and 31x on OSRM in measurement, with identical distances and durations. Honoured by Google/Mapbox/OSRM; silently ignored by HERE/TomTom/Esri, which expose no equivalent knob. |
$trafficMode |
TrafficMode::None |
Whether to route against live traffic. Live selects a Pro-tier SKU on Google, so it is never enabled implicitly — not even by passing $departureTime. |
$include |
[] |
Which optional output fields to fetch. Each token maps 1:1 onto one optional result field. |
use Thinwrap\Location\Enum\{PolylineQuality, RoutingInclude, TrafficMode}; $result = $routing->route(new RoutingOptions( waypoints: $waypoints, trafficMode: TrafficMode::Live, // opt into traffic-aware routing polylineQuality: PolylineQuality::Detailed, // opt into full geometry include: [RoutingInclude::DurationWithoutTraffic], // opt into the extra output field )); // Present only when requested AND returned natively — never synthesized, so null // tells you this provider did not supply it. $congestion = $result->totalDurationWithoutTrafficSeconds !== null ? $result->totalDurationSeconds - $result->totalDurationWithoutTrafficSeconds : null;
DurationWithoutTraffic is native on Google (staticDuration), HERE (baseDuration)
and TomTom (noTrafficTravelTimeInSeconds); Mapbox, OSRM and Esri do not return it, so
the field stays null there rather than being faked.
Making OSRM's avoid-flags work
Whether OSRM accepts exclude=toll is a property of your server, not of OSRM. The
same request was verified live against two builds with opposite results: the public demo
build rejects it with InvalidValue, while a self-hosted instance honoured it and
genuinely rerouted (138075 m / 5890 s via the toll road → 130421 m / 6513 s without).
Stock OSRM compiles no exclude classes, so the flags are rejected up front by default. If your profile was built with them, declare it:
$routing = new Routing(LocationProviderId::Osrm, new OsrmConfig( baseUrl: 'https://routing.internal', supportedExcludeClasses: ['toll', 'ferry'], ));
Autocomplete → place details
autocomplete() returns predictions; placeDetails() resolves one into a full
candidate. "Place details" and "geocode by place id" are the same vendor call on all
five providers, so this is one operation, not two — and it returns an ordinary
GeocodeCandidate.
$geocoding = new Geocoding(LocationProviderId::Google, new GoogleConfig(apiKey: $key)); $predictions = $geocoding->autocomplete(new AutocompleteOptions(input: 'blue bottle'))->predictions; // Render the usual two-line suggestion without splitting `description` on a comma. foreach ($predictions as $p) { echo $p->structuredFormat?->mainText ?? $p->description, "\n"; echo $p->structuredFormat?->secondaryText ?? '', "\n"; } $details = $geocoding->placeDetails(new PlaceDetailsOptions(placeId: $predictions[0]->placeId));
placeId values are provider-scoped — a Google place id is meaningless to Mapbox.
Two things that cost money here
Google's Place Details SKU is driven by the field mask, so name (displayName) is
a Pro-tier field and only requested behind an opt-in:
$geocoding->placeDetails(new PlaceDetailsOptions( placeId: $placeId, include: [PlaceDetailsInclude::Name], ));
Note this is the opposite of Compute Routes, whose SKU is driven by request features — check per API rather than generalizing.
Mapbox Search Box bills per session, not per request. A suggest and the
retrieve that follows count as one billable session only when they carry the same
session_token:
$token = bin2hex(random_bytes(16)); // one per user interaction $mapbox->autocomplete(new AutocompleteOptions( input: $input, passthrough: new Passthrough(query: ['session_token' => $token]), )); $mapbox->placeDetails(new MapboxPlaceDetailsOptions(placeId: $placeId, sessionToken: $token));
The wrapper cannot generate or remember that token — it holds no state.
structuredFormat support
| Provider | mainText / secondaryText |
|---|---|
structuredFormat.mainText / .secondaryText — default-on, free |
|
| Mapbox | name / place_formatted |
| HERE | title / address.label — secondaryText null for query-type suggestions, which carry no address |
| TomTom | poi.name / address.freeformAddress — null for street results, which have no poi.name |
| Esri | not supported — returns a single flat text |
It is never synthesized: a null structuredFormat means the provider gave no
distinct main part, and $description remains the thing to render.
Switching providers
Change the LocationProviderId case and config DTO; the input and output shape stay identical.
use Thinwrap\Location\Config\MapboxConfig; $a = new Routing(LocationProviderId::Google, new GoogleConfig(apiKey: getenv('GOOGLE_KEY'))); $b = new Routing(LocationProviderId::Mapbox, new MapboxConfig(accessToken: getenv('MAPBOX_TOKEN'))); $sameInput = new RoutingOptions( waypoints: [$origin, $destination], travelMode: 'driving', ); $ra = $a->route($sameInput); $rb = $b->route($sameInput); // $ra and $rb share the same RoutingResult shape: // { legs, totalDistanceMeters, totalDurationSeconds, polyline, waypointOrder?, raw }
Bring your own PSR-18 client
Inject any PSR-18 client through the third constructor argument on the facade — useful for
tracing, retries, mocking, or proxying through symfony/http-client. The *Config DTO
carries only credentials; the HTTP client is a facade-level seam.
Contract: a non-2xx must be RETURNED, not thrown. PSR-18 requires this and compliant
clients honour it, so each connector can map the status to a ProviderCode
(429 → RateLimited, 401 → AuthFailed, …) and read the vendor's message. A client that
raises instead — Guzzle used outside its PSR-18 adapter, or a decorator calling
raise-on-error — is handled defensively: the answered response is recovered from the
exception's getResponse() so classification still runs.
use GuzzleHttp\Client; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; $tracingClient = new class(new Client()) implements ClientInterface { public function __construct(private Client $inner) {} public function sendRequest(RequestInterface $req): ResponseInterface { error_log('→ ' . $req->getMethod() . ' ' . (string) $req->getUri()); return $this->inner->sendRequest($req); } }; $routing = new Routing( LocationProviderId::Google, new GoogleConfig(apiKey: getenv('GOOGLE_KEY')), $tracingClient, );
The wrapper holds no state — no token cache, no connection pool, no retry buffer. Every
operation is a single function call from input to output with one HTTP round-trip (except
HERE Matrix v8, which transparently runs a submit → poll → retrieve cycle behind a single
$matrix->matrix($input) call).
Error handling
Every failure surfaces as ConnectorError with a typed ProviderCode. Compose your own
retry strategy from $e->providerCode and $e->cause (which carries the raw
Retry-After header where the vendor sets one).
use Thinwrap\Location\ConnectorError; use Thinwrap\Location\Enum\ProviderCode; try { $routing->route($input); } catch (ConnectorError $e) { match ($e->providerCode) { ProviderCode::RateLimited => /* respect Retry-After in $e->cause */ null, ProviderCode::AuthFailed => /* rotate credentials */ null, ProviderCode::InvalidRequest => /* fix payload */ null, ProviderCode::InvalidRecipient => /* fix destination */ null, ProviderCode::ProviderUnavailable => /* transient 5xx — your retry strategy */ null, ProviderCode::UnsupportedField => /* drop OSRM-incompatible field */ null, ProviderCode::UnsupportedOption => /* drop OSRM-incompatible option */ null, ProviderCode::UnsupportedTravelMode => /* fall back to a supported travel mode */ null, ProviderCode::ProfileNotConfigured => /* compile the OSRM profile */ null, ProviderCode::MatrixPollingTimeout => /* resume via $e->cause['matrixId'] */ null, ProviderCode::NoRoute => /* no route between these points */ null, ProviderCode::Timeout => /* request exceeded the client's bound */ null, ProviderCode::Unknown => /* fallback */ null, }; }
no_route — "there is no route", normalized
The providers agree on nothing here. Google answers HTTP 200 with the routes key
absent; HERE 200 with routes: [] plus a notices[].code; Mapbox code: "NoRoute" on
either 200 or 422; OSRM the same codes on a 400; TomTom a 400 with
detailedError.code; Esri a 200 whose in-body error.code: 400 names an unlocated
stop in details[]. Branching on "no usable route" used to mean reimplementing all six.
In practice it almost always means a waypoint could not be matched to the road network rather than the road network is disconnected: every provider tested happily routes Reykjavik→Oslo via ferry.
timeout
Separated from provider_unavailable because it is the one transport failure a caller
acts on differently — back off and retry, versus treat the provider as down.
On PHP the Timeout classification is best-effort: the HTTP client is BYO
(PSR-18), which defines no timeout-specific exception type, so it is read from the
client's message (cURL error 28 and friends). Configure the timeout on your own client.
The wrapper performs no automatic retry. The Retry-After header (when present on
HTTP 429) is surfaced via $e->cause['retryAfter'] (raw header string) and the parsed
seconds count is woven into $e->providerMessage (…; retry after N seconds). There is
no structured retryAfterSeconds field on ConnectorError.
$e->providerMessage is safe to log — known credential query params are redacted from
transport-error messages. But $e->cause and $e->getPrevious() retain the raw underlying
HTTP-client exception, which may embed the full request URL and headers (including live
credentials); do not log them unfiltered.
_passthrough escape valve
When the normalized input doesn't expose a vendor-specific field, forward arbitrary keys
via the Passthrough DTO on the operation options. The wrapper deep-merges body,
shallow-merges headers and query. Consumer values win on conflict. Keys are forwarded
verbatim — no casing transformation.
use Thinwrap\Location\DTO\Passthrough; $routing->route(new RoutingOptions( waypoints: [$origin, $destination], passthrough: new Passthrough( body: ['languageCode' => 'fr', 'units' => 'IMPERIAL'], headers: ['X-Goog-FieldMask' => 'routes.legs.distanceMeters,routes.duration'], query: ['region' => 'us'], ), ));
Each per-connector README documents its vendor-specific _passthrough examples.
Polyline utilities
use Thinwrap\Location\Util\Polyline; $latLngs = Polyline::decodePolyline($result->polyline); // list<LatLng> $re = Polyline::encodePolyline($latLngs); // back to precision-5 $here = Polyline::decodeFlexPolyline('BFoz5...'); // HERE flex-polyline $esri = Polyline::encodeEsriPaths([[[-74, 40], [-73.5, 40.5]]]); // ESRI paths
All facades emit Google precision-5 encoded polyline on $result->polyline. The four
public static methods on Polyline are the only encode/decode primitives exported —
locked at v1.0.
Language constraints
- PHP 8.2 minimum; PHPStan level 8 expected for consumer code that uses union-typed config narrowing.
- Runs on PHP 8.2, 8.3, and 8.4 (CI matrix; Linux only at v1.0 — Windows / macOS deferred to v1.1).
declare(strict_types=1)is required on every file in this library and recommended for consumer code.- Only three runtime dependencies —
psr/http-client+psr/http-factory(interfaces) andphp-http/discovery, which auto-wires a PSR-18 client when none is injected. No vendor SDKs. - Server-only. Most providers require server-only secrets — there is no browser story.
Public API surface (locked at v1.0)
| Category | Exports |
|---|---|
| Facades | Routing, Matrix, Geocoding, Isochrone (top-level under Thinwrap\Location\) |
| Error | ConnectorError, Thinwrap\Location\Enum\ProviderCode |
| Geometry | Thinwrap\Location\DTO\LatLng, Thinwrap\Location\Util\Polyline (4 static methods: encodePolyline, decodePolyline, decodeFlexPolyline, encodeEsriPaths) |
| Routing connectors | GoogleRoutingConnector, MapboxRoutingConnector, HereRoutingConnector, EsriRoutingConnector, TomTomRoutingConnector, OsrmRoutingConnector |
| Matrix connectors | GoogleMatrixConnector, MapboxMatrixConnector, HereMatrixConnector, EsriMatrixConnector, TomTomMatrixConnector, OsrmMatrixConnector |
| Geocoding connectors | GoogleGeocodingConnector, MapboxGeocodingConnector, HereGeocodingConnector, EsriGeocodingConnector, TomTomGeocodingConnector |
| Isochrone connectors | MapboxIsochroneConnector, HereIsochroneConnector, EsriIsochroneConnector, TomTomIsochroneConnector |
| Config DTOs | GoogleConfig, MapboxConfig, HereConfig, EsriConfig, TomTomConfig, OsrmConfig |
| Enums | LocationProviderId, ProviderCode, TravelMode, IsochroneType |
Per-connector documentation
Each per-connector README documents auth, endpoints (regional/sandbox), narrowed input
augmentations, outlier translations, error-code mappings, and _passthrough examples.
Routing (6)
| Provider | README |
|---|---|
google |
src/Providers/Google/README.md |
mapbox |
src/Providers/Mapbox/README.md |
here |
src/Providers/Here/README.md |
esri |
src/Providers/Esri/README.md |
tomtom |
src/Providers/TomTom/README.md |
osrm |
src/Providers/Osrm/README.md |
Matrix (6)
| Provider | README |
|---|---|
google |
src/Providers/Google/README.md |
mapbox |
src/Providers/Mapbox/README.md |
here |
src/Providers/Here/README.md |
esri |
src/Providers/Esri/README.md |
tomtom |
src/Providers/TomTom/README.md |
osrm |
src/Providers/Osrm/README.md |
Geocoding (5)
| Provider | README |
|---|---|
google |
src/Providers/Google/README.md |
mapbox |
src/Providers/Mapbox/README.md |
here |
src/Providers/Here/README.md |
esri |
src/Providers/Esri/README.md |
tomtom |
src/Providers/TomTom/README.md |
Isochrone (4)
| Provider | README |
|---|---|
mapbox |
src/Providers/Mapbox/README.md |
here |
src/Providers/Here/README.md |
esri |
src/Providers/Esri/README.md |
tomtom |
src/Providers/TomTom/README.md |
Baseline-coverage discipline
The unified facade surface includes only features ≥90% of providers natively support.
Sub-baseline fields are accessible via the Passthrough escape hatch, plus the one
per-provider narrowed type that exists at v1.0 (HERE routing, src/Providers/Here/DTO/).
Migrating
From googlemaps/google-maps-services-php
// Before — googlemaps/google-maps-services-php $client = new \GoogleMaps\Client(['key' => 'YOUR_KEY']); $response = $client->directions([...]); // After use Thinwrap\Location\Enum\LocationProviderId; use Thinwrap\Location\Config\GoogleConfig; use Thinwrap\Location\Routing; $routing = new Routing(LocationProviderId::Google, new GoogleConfig(apiKey: 'YOUR_KEY')); $result = $routing->route(new RoutingOptions(waypoints: [$origin, $destination]));
From mapbox/mapbox-sdk-php (community port)
// Before — community Mapbox SDK $mapbox = new \Mapbox\Mapbox(['access_token' => 'YOUR_TOKEN']); $directions = $mapbox->directions([...]); // After use Thinwrap\Location\Config\MapboxConfig; $routing = new Routing(LocationProviderId::Mapbox, new MapboxConfig(accessToken: 'YOUR_TOKEN')); $result = $routing->route(new RoutingOptions(waypoints: [$origin, $destination]));
From raw HTTP / Guzzle
If you've been hand-rolling vendor HTTP calls with Guzzle, the facade collapses the boilerplate to one line per call. Error handling and retry composition stay yours.
For AI agents and contributors
.ai/guidelines.md— contributor entry point: how to add a connector..ai/ARCHITECTURE.md— 6 location-distinctive invariants + PHP rules..ai/CONVENTIONS.md— naming, file layout, test patterns.
Security
Report vulnerabilities privately — please do not open a public issue. Preferred: a private security advisory on this repository. Alternatively, email security@thinwrap.dev. Include the affected versions and a minimal reproduction if you have one.
A vulnerability in a provider's own API or service belongs to that vendor rather than to this wrapper — please report those upstream.
Supply chain: releases are cosign-signed via GitHub Actions OIDC (no static signing keys), maintainer accounts require two-factor authentication on GitHub, and Packagist consumes the package via webhook auto-sync — no long-lived Packagist API token is stored anywhere.
License
MIT.