|
| 1 | +# Mapbox Geocoding v6 — Design |
| 2 | + |
| 3 | +Issue: https://github.com/geocoder-php/Geocoder/issues/1237 |
| 4 | +Date: 2026-08-28 |
| 5 | +Status: validated |
| 6 | + |
| 7 | +## Background |
| 8 | + |
| 9 | +Mapbox released the [Geocoding API v6](https://docs.mapbox.com/api/search/geocoding/) (GA). The v6 |
| 10 | +API is more precise, adds **Structured Input** (typed address fields instead of free text), |
| 11 | +**Smart Address Match** (`match_code`), point `accuracy`, `routable_points`, and new feature |
| 12 | +types. The v5 API keeps running; Mapbox does not force a migration. |
| 13 | + |
| 14 | +## Decision |
| 15 | + |
| 16 | +**Upgrade the existing `Geocoder\Provider\Mapbox\Mapbox` provider in place, as a major |
| 17 | +version of `geocoder-php/mapbox-provider` (1.x → 2.0.0).** All breaking changes are listed |
| 18 | +in the provider CHANGELOG. Users who need v5 pin to `^1.5`. |
| 19 | + |
| 20 | +Rationale: the issue asks for *the* Mapbox provider to support v6; the repo's deprecation |
| 21 | +procedure (`docs/procedures/provider-deprecation.md`) only applies once an API stops |
| 22 | +responding, which has not happened for v5 — but the provider may move forward in a major |
| 23 | +release. (A parallel `MapboxV6` class was considered and rejected in favor of a clean |
| 24 | +major bump.) |
| 25 | + |
| 26 | +## Verified v6 API behavior (probed against the live API) |
| 27 | + |
| 28 | +- Forward: `GET https://api.mapbox.com/search/geocode/v6/forward?q={text}` (or structured fields, no `q`) |
| 29 | +- Reverse: `GET https://api.mapbox.com/search/geocode/v6/reverse?longitude={lon}&latitude={lat}` |
| 30 | +- Invalid token → **401** (v5 used 403). Both map to `InvalidCredentials` in `AbstractHttpProvider`. |
| 31 | +- `types=poi` / `types=poi.landmark` → **422** (POI geocoding removed in v6). |
| 32 | +- `types` valid values (per the API's own 422 message): `country, region, postcode, district, |
| 33 | + place, locality, neighborhood, street, block, address, secondary_address`. `block` is only |
| 34 | + accepted in Japan context (`country=jp`), `secondary_address` in US context; the docs list |
| 35 | + the 9 globally-safe values. |
| 36 | +- Forward `limit=11` → 200 (silently clamped to the max of 10). |
| 37 | +- Reverse `limit>1` **requires exactly one** `types` value, else 422. |
| 38 | +- `autocomplete` (replaces v5 `fuzzyMatch`, inverted default): prefix-style matching; a typo'd |
| 39 | + query (`wahsington`) returns nothing in v6 — there is no typo tolerance; that is what |
| 40 | + `match_code` confidence is for. |
| 41 | +- Structured Input: `q` is dropped in favor of `address_line1`, `address_number`, `street`, |
| 42 | + `block`, `place`, `region`, `postcode`, `locality`, `neighborhood` (plus `country`); Mapbox |
| 43 | + recommends `autocomplete=false` for structured input. |
| 44 | +- Response: GeoJSON. `properties` contains `mapbox_id`, `feature_type` (string, formerly |
| 45 | + `place_type` array), `name`, `full_address`, `coordinates{longitude,latitude,accuracy, |
| 46 | + routable_points}`, `match_code{... ,confidence}` (forward only) and `context` as an |
| 47 | + **object keyed by feature type** (v5 was an array of `{id,text,short_code}`). |
| 48 | + `context.country` uses `country_code`, `context.region` uses `region_code`/`region_code_full`, |
| 49 | + `context.address` uses `street_name`/`address_number`. |
| 50 | +- `bbox` is documented on non-address features but is **not currently returned** in any of the |
| 51 | + probed responses; the parser maps it defensively when present. |
| 52 | + |
| 53 | +## Public API surface (2.0.0) |
| 54 | + |
| 55 | +### Constructor (breaking) |
| 56 | + |
| 57 | +```php |
| 58 | +new Mapbox(ClientInterface $client, string $accessToken, ?string $country = null, bool $permanent = false) |
| 59 | +``` |
| 60 | + |
| 61 | +The 4th argument changes from `string $geocodingMode` to `bool $permanent` (v6 `permanent=true` |
| 62 | +storage, applied to forward and reverse requests). |
| 63 | + |
| 64 | +### Constants |
| 65 | + |
| 66 | +- `FORWARD_ENDPOINT_URL`, `REVERSE_ENDPOINT_URL` (replace `GEOCODE_ENDPOINT_URL_SSL` / |
| 67 | + `REVERSE_ENDPOINT_URL_SSL`). |
| 68 | +- Types: `country, region, postcode, district, place, locality, neighborhood, street, address` |
| 69 | + in `TYPES` (adds `TYPE_STREET`; removes `TYPE_POI` / `TYPE_POI_LANDMARK`). |
| 70 | +- `TYPE_BLOCK` (Japan) and `TYPE_SECONDARY_ADDRESS` (US) exposed as constants but **not** in |
| 71 | + `TYPES` (conditionally accepted by the API only). |
| 72 | +- `STRUCTURED_INPUT_FIELDS`: the data keys that trigger Structured Input. |
| 73 | +- Removed: `GEOCODING_MODE_PLACES`, `GEOCODING_MODE_PLACES_PERMANENT`, `GEOCODING_MODES`. |
| 74 | + |
| 75 | +### Query data keys |
| 76 | + |
| 77 | +| Key | v6 param | Note | |
| 78 | +|---|---|---| |
| 79 | +| `location_type` (string\|array) | `types` | kept from v5; default `address` | |
| 80 | +| `autocomplete` (bool) | `autocomplete` | replaces `fuzzy_match` (removed) | |
| 81 | +| `country` (string) | `country` | kept; also a structured field | |
| 82 | +| `proximity` (`"lon,lat"`\|`"ip"`) | `proximity` | new | |
| 83 | +| `worldview` (string) | `worldview` | new | |
| 84 | +| `address_line1`, `address_number`, `street`, `block`, `place`, `region`, `postcode`, `locality`, `neighborhood` | same | structured input | |
| 85 | +| `bounds` → `bbox`, `locale` → `language`, `limit` → `limit` | | kept | |
| 86 | + |
| 87 | +**Mode selection:** if any structured field is present, `q` is not sent and `autocomplete` |
| 88 | +defaults to `false` (unless the user sets it). Otherwise `q` = query text. (The text must |
| 89 | +still be non-empty because `GeocodeQuery::create('')` throws; it is simply not sent in |
| 90 | +structured mode.) |
| 91 | + |
| 92 | +### Parameter order (deterministic URLs) |
| 93 | + |
| 94 | +- Forward: `q` | structured fields (canonical order) → `bbox` → `types` → `autocomplete` → |
| 95 | + `proximity` → `worldview` → `country` → `language` → `limit` → `permanent` (if true) → |
| 96 | + `access_token` |
| 97 | +- Reverse: `longitude` → `latitude` → `types` → `country` → `language` → `limit` → |
| 98 | + `permanent` (if true) → `access_token` |
| 99 | + |
| 100 | +### Response → model mapping |
| 101 | + |
| 102 | +| Model | v6 source | |
| 103 | +|---|---| |
| 104 | +| `id` | `properties.mapbox_id` (fallback: feature `id`) | |
| 105 | +| `streetName` | `context.address.street_name` → `context.street.name` → `properties.name` (keeps v5 semantics: no house number) | |
| 106 | +| `streetNumber` | `context.address.address_number` | |
| 107 | +| `formattedAddress` | `properties.full_address` | |
| 108 | +| `neighborhood` | `context.neighborhood.name` | |
| 109 | +| `resultType` | `[properties.feature_type]` (array kept for BC) | |
| 110 | +| `postalCode` / `locality` / `country` (+code) / admin levels | `context.postcode.name`, `context.locality.name` + `place.name` (→ locality + level 1), `context.region.name` + `region_code` (→ level 2), `context.country.name` + `country_code` — same precedence rules as v5 (later context entry wins for locality) | |
| 111 | +| `bounds` | feature `bbox` (when present) | |
| 112 | +| **new** `matchCode` / `matchConfidence` | `properties.match_code` / `.confidence` | |
| 113 | +| **new** `accuracy` | `properties.coordinates.accuracy` | |
| 114 | + |
| 115 | +Errors/behavior: IP text → `UnsupportedOperation` (unchanged); 401/403 → `InvalidCredentials`, |
| 116 | +429 → `QuotaExceeded`, other ≥300 → `InvalidServerResponse` (base class, unchanged); |
| 117 | +`getName()` stays `mapbox`; client-side result limit kept. |
| 118 | + |
| 119 | +## Testing strategy (TDD) |
| 120 | + |
| 121 | +1. **Mocked unit tests** — no network: |
| 122 | + - basics: name, IP rejection ×3, 429, 401, empty reverse response; |
| 123 | + - exact-URL assertions via `getMockedHttpClientCallback` (forward basic / all options / |
| 124 | + structured / explicit autocomplete override / reverse / reverse with types+country); |
| 125 | + - response-parsing tests with inline JSON fixtures (address feature, place feature, street |
| 126 | + feature, feature without context, empty features, invalid JSON). |
| 127 | +2. **Integration tests with cached responses** (repo convention): 7 real v6 queries recorded |
| 128 | + with a real key (see implementation plan for the exact URLs); `CachedResponseClient` |
| 129 | + masks the key as `[apikey]` so CI replays the fixtures with the placeholder key. |
| 130 | + v5 fixtures are deleted. |
| 131 | +3. `composer test` (full suite), `composer analyse` (PHPStan L6), `composer cs` — per |
| 132 | + `CONTRIBUTING.md`. |
| 133 | + |
| 134 | +## Out of scope (YAGNI) |
| 135 | + |
| 136 | +- Batch geocoding endpoint (`/v6/batch`) |
| 137 | +- `entrances` (public preview) |
| 138 | +- `routable_points` on the model |
| 139 | +- `format=v5` compatibility mode (we parse native v6) |
| 140 | +- Deprecation of the v5 API (it still works; no maintainer decision to deprecate) |
0 commit comments