Search by

thinwrap / location

danikp

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.

v1.3.0 2026-09-29 09:37 UTC

This package is auto-updated.

Last update: 2026-09-29 10:06:45 UTC


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;

// Construction errors (e.g. a mis-paired config) are bugs in your code, not ConnectorErrors.
$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 durations account for time-of-day traffic (see Traffic below). Traffic selects a Pro-tier SKU on Google and bills HERE routes as Time-Aware Routing, so it is never enabled implicitly — not even by passing $departureTime. The same option exists on MatrixOptions.
$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::Traffic,                  // 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.

Traffic

$trafficMode on RoutingOptions and MatrixOptions takes one of:

  • TrafficMode::None (default): no time-of-day traffic. Durations use the provider's time-independent speeds: free-flow on some providers, a time-independent historical average on others, so not guaranteed to be free-flow. A $departureTime never adds traffic under None; where the time only feeds traffic, the connector drops it.
  • TrafficMode::Traffic: the traffic expected at $departureTime, live when that is close to now and typical/predicted for later times. With no $departureTime, now.
  • TrafficMode::Live: deprecated alias of Traffic with identical behaviour. It is kept, but new code should use Traffic.
Provider None Traffic
Google (routes, matrix) Exact: TRAFFIC_UNAWARE, the time is dropped TRAFFIC_AWARE (+ departureTime). Pro SKU
HERE (routes, matrix, optimize) Exact: departureTime=any, or the time + traffic[mode]=disabled HERE's default (+ the time). Time-Aware Routing SKU on routes
Esri (routing, matrix) Exact: the time is dropped now, or the time (live within 4 h of now, typical otherwise)
Mapbox Directions (driving) Exact: driving, depart_at dropped driving-traffic (+ depart_at)
Mapbox Matrix Exact: driving, the time dropped driving-traffic; with a time, unsupported_field (depart_at is a sign-up beta)
Mapbox Optimization Exact: the time dropped Ignored (no traffic profile); with a time, unsupported_field
TomTom routing Approximate: traffic=false, the time is still sent. TomTom has no time-independent routing mode, so its typical (historic) traffic for the departure time, or for now, still applies traffic=true (+ departAt)
TomTom matrix Approximate: historical + departAt: any, a time-independent average live + departAt (now when no time)
OSRM Exact: static profile speeds; a time has no effect Ignored (no traffic data); a time has no effect

Walking and cycling have no traffic anywhere, so Traffic changes nothing for them. Traffic costs money on two providers: Google bills traffic-aware requests on the Pro SKU (per element on the matrix), and HERE bills a traffic route as Time-Aware Routing. It has no separate price elsewhere.

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));
if ($details->candidate === null) {
    // The provider could not find the place (e.g. an expired id) — not an error.
}

placeId values are provider-scoped — a Google place id is meaningless to Mapbox.

A place the provider cannot find — a 404 or an empty result list, or a place returned without a location — comes back as candidate: null (and name: null), with the vendor body still on raw. An id the provider rejects as invalid still raises ConnectorError with invalid_request; Google Places reports even a well-formed but unknown id that way (400 INVALID_ARGUMENT), and Esri a stale magicKey.

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. Every suggest keystroke 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 MapboxAutocompleteOptions(input: $input, sessionToken: $token));
$mapbox->placeDetails(new MapboxPlaceDetailsOptions(placeId: $placeId, sessionToken: $token));

Google Places works the same way with GoogleAutocompleteOptions / GooglePlaceDetailsOptions and their typed sessionToken.

The wrapper cannot remember that token — it holds no state. Both endpoints require a session_token, so when a call carries none the connector generates a fresh v4 UUID — which makes that call its own billed session.

structuredFormat support

Provider mainText / secondaryText
Google 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.

Contract: a 3xx must be RETURNED, not followed. HERE Matrix completes with a 303 whose target must be fetched with the apiKey the connector adds; a client that follows the redirect itself drops the key. Guzzle's PSR-18 sendRequest() never follows redirects, but Symfony's Psr18Client does by default (max_redirects: 20) — build it with new Psr18Client(HttpClient::create(['max_redirects' => 0])). A redirect-following client surfaces as InvalidRequest with a message saying so rather than as a bad key.

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 operation failure surfaces as ConnectorError with a typed ProviderCode: a vendor error, a transport failure, a timeout, or request data the library rejects before sending it (non-finite coordinates, too few waypoints, a malformed country code, …). Compose your own retry strategy from $e->providerCode and $e->cause (which carries the raw Retry-After header where the vendor sets one).

Misusing the API itself is a bug in your code, not a runtime failure. It is reported with PHP's native exception, as early as possible, and is deliberately not a ConnectorError:

  • \TypeError — the engine: a wrong argument type, a config outside the facade's union.
  • \ValueError — a string outside an enum, e.g. travelMode: 'flying'. Validate end-user input at your boundary with TravelMode::tryFrom($input).
  • \LogicException — the config doesn't match the provider id, or the provider lacks the operation (e.g. new Isochrone(LocationProviderId::Osrm, …)).
  • \InvalidArgumentException — EsriConfig with neither or both credentials.
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. The raw HTTP-client exception is deliberately NOT attached, because it embeds the full request URL (the credential, for query-key providers): on a transport failure $e->getPrevious() is null and $e->cause carries only the exception's class name ($e->cause['raw']['exception']). On a vendor error $e->cause carries the vendor's response body.

_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. Header names match case-insensitively, as HTTP defines them: x-goog-fieldmask replaces the connector's X-Goog-FieldMask (it is not sent twice), and your spelling is the one sent.

use Thinwrap\Location\DTO\Passthrough;
use Thinwrap\Location\Providers\Google\GoogleDefaults;

$routing->route(new RoutingOptions(
    waypoints: [$origin, $destination],
    passthrough: new Passthrough(
        body:    ['languageCode' => 'fr', 'units' => 'IMPERIAL'],
        headers: ['X-Goog-FieldMask' => implode(',', [...GoogleDefaults::ROUTES_FIELD_MASK, 'routes.travelAdvisory'])],
        query:   ['region' => 'us'],
    ),
));

A list-valued vendor parameter the connector already sends — Google's X-Goog-FieldMask, HERE's return and matrixAttributes, Mapbox and OSRM matrix annotations, Esri accumulateAttributeNames — is replaced by your value, not merged, so a default can also be dropped. The connector's own lists are published on GoogleDefaults, HereDefaults, MapboxDefaults, OsrmDefaults and EsriDefaults (under Thinwrap\Location\Providers\…) to append to; leaving out a field the normalizer reads empties or zeroes that output, usually without an error. Each of those provider READMEs has a "Replacing a default list" section.

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) and php-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)

Each contour's geometry is a GeoJSON Polygon — or a MultiPolygon when the provider reports a band as several disconnected areas (an island reached by ferry, a region split by water), so check geometry['type'] before reading coordinates. Holes are kept, rings are closed, and winding follows RFC 7946 (exterior counter-clockwise, holes clockwise) whatever the provider sends.

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

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.