Skip to content

Geocoding

phil/geo

The ports

TypeRole
Geocoding\Geocodergeocode(string $address, ?string $locale) and reverse(Coordinates $at, ?string $locale): places, the best first
Geocoding\IpLocatorlocate(string $ip): a place, roughly — a city at best —, or null
Geocoding\Placecoordinates, provider, label, street, locality, region, postalCode, countryCode
Exception\GeocodingFailedA provider that could not answer

A part of an address a provider does not give is null; a country is its two letters of ISO 3166-1, in capitals. A provider that answers that it found nothing gives an empty list, never a failure. A provider that is unreachable, refuses the key, is over its quota, or answers what it should not — a place without coordinates, coordinates out of bounds, a country that is not two letters, something other than JSON — is a GeocodingFailed. Its message gives the provider and the HTTP status or the kind of failure, never the request: the URL of a request holds the key and the address or IP address asked about, so neither the HTTP client's message nor its exception is kept, and a log or an error page that shows the failure shows no secret and no personal information.

The ports and the places name nothing but this library: a domain model may hold them.

The adapters

They take the HttpClientInterface of Symfony.

AdapterTakesAsks
NominatimGeocoderthe user agent, a base URL — the public server by default —, the most places (5)/search and /reverse, jsonv2, with the details of the address
GoogleMapsGeocoderthe key, a base URLthe Geocoding API: address, or latlng
NrcanGeocodera base URL — the service of Natural Resources Canada —, the most places (5)/{en,fr}/locate?q=
PhotonGeocodera base URL — the public server —, the most places (5), the languages the server knows (de, en, fr)/api?q= and /reverse?lat=&lon=, GeoJSON
OpenCageGeocoderthe key, a base URL, the most places (5)?q= an address or lat,lng, without annotations
HereGeocoderthe key, the geocode and reverse URLs, the most places (5)/geocode?q= and /revgeocode?at=, with show=countryInfo
MapboxGeocoderthe access token, a base URL, the most places (5), whether answers are permanent/forward?q= and /reverse?longitude=&latitude= of the v6 API
AzureMapsGeocoderthe subscription key, a base URL, the most places (5), the API version (2026-01-01)/geocode?query= and /reverseGeocode?coordinates=
AdressesQuebecGeocodera base URL — the service of the Gouvernement du Québec —, the most places (5)/findAddressCandidates?SingleLine= and /reverseGeocode?location=, in WGS 84
IpstackLocatorthe access key, a base URL — https://api.ipstack.com/{ip}

The locale given becomes accept-language for Nominatim and language for Google Maps. Nominatim's usage policy asks for a user agent that names the site and how to reach it, and one request a second at most on its public server. ipstack refuses an IP address that is not one before asking. It asks over HTTPS; a plan that answers over plain HTTP only takes http://api.ipstack.com as its base URL, and every lookup then sends the visitor's IP address — personal information under Québec's Law 25 — and the access key in clear. MaxMindLocator reads a database on the server and sends nothing.

The geolocation service of Natural Resources Canada is free and asks for no key, for Canada only: street addresses, intersections, postal codes and the names of the Canadian Geographical Names Database. A locale in French asks for French titles, any other for English ones. It finds places from words and never from a point, so reverse() finds nothing and a chain asks the next geocoder. Its places carry the title the service gives as their label, CA as their country, and the postal code when that is what it found; it gives no other part of an address. An answer that is a message instead of places is a GeocodingFailed.

Photon finds places in OpenStreetMap data as one types. Its public server asks for no key and for reasonable use — it throttles heavy users —; one's own server is a base URL. A server refuses a language it does not know, so a locale is sent as lang only when its language — fr for fr_CA — is one the geocoder was given; otherwise places come in their local names. A place carries its street — number and street —, city, state, postal code and country code, and a label made of its name and these parts; a place that is a street, a city or a state gives its name as that part.

OpenCage, HERE and Mapbox take the locale as an IETF tag — fr-CA for fr_CA —, Azure Maps as its Accept-Language header. Their terms differ on what an application may keep, which matters to CachedGeocoder:

ProviderKeyWhat may be keptA point gives
OpenCageits dashboard'severything, as long as one likesone place at most
HEREthe HERE platform'sthirty days at most, unless the right to keep it is bought: a cache lifetime under thirty daysup to the most places
Mapboxan access tokennothing, unless permanent — billed —; its temporary answers are shown only with a Mapbox mapone place
Azure Mapsthe Azure account'sas the Microsoft Product Terms saythe most precise place
Adresses Québecnoneits data is under CC BY 4.0: credit the Ministère des Ressources naturelles et des Forêtsthe address there, or none

The country is the alpha-2 code each gives — HERE's from countryInfo, which the adapter asks for. Adresses Québec gives addresses of Québec only, from the province's address register, without language, region or country: its places have Québec and CA, and a postal code as A1A 1A1. Its candidates come in no order, so they are sorted by score; a street matched without a number has no street part. A point where it finds no address answers an error, read as no place; an error on an address is a GeocodingFailed. The Gouvernement du Québec is replacing the service with one on its new address register, announced for 2026: the base URL is configurable.

$geocoder = new CachedGeocoder(
    new ChainGeocoder([
        new NominatimGeocoder($httpClient, 'Example (contact@example.com)'),
        new GoogleMapsGeocoder($httpClient, $key),
    ]),
    $cache,
    604800,
);

$places = $geocoder->geocode('4545 avenue Pierre-De Coubertin, Montréal', 'fr');
$places[0]->coordinates;    // Coordinates

Chain and cache

ChainGeocoder asks its geocoders in turn and gives the places of the first that finds some. One that fails is passed over; when every one fails, the last failure is thrown; when none finds anything, there is no place.

CachedGeocoder keeps an answer for its lifetime — a week by default — in a cache of symfony/cache-contracts, by the operation, the address or the point, and the locale. A failure is not kept.

MaxMind databases, on the server

MaxMindLocator locates an IP address in a MaxMind city database kept on the server — GeoLite2 City, free with a MaxMind account, or GeoIP2 City. Nothing leaves the server: no request, no key, no quota, and the visitor's address stays with the site, which a privacy law such as Québec's Law 25 favours.

use Phil\Geo\Geocoding\Adapter\MaxMindLocator;
use Phil\Geo\Geocoding\MaxMind\Database;

$locator = new MaxMindLocator(new Database('/var/lib/geoip/GeoLite2-City.mmdb'), 'fr');

$place = $locator->locate('81.2.69.142');
$place?->locality;     // "Londres", or the English name when the database has no French one
$place?->countryCode;  // "GB"
  • The place has the city, the first subdivision as its region, the postal code and the country; its label is the city, the region and the country's name, in the locale given.
  • A network the database does not hold, or a record without a position — every record of a country database —, gives no place.
  • An address that is not one throws InvalidPlace, whose message shows at most its first 64 bytes, control characters escaped; a file that is missing, unreadable or broken, InvalidDatabase.

Database reads any MaxMind DB file, as its format specification (version 2.0) describes: find($ip) gives the record of the network an address belongs to, as the database holds it, and metadata() what the file says of itself. The file is opened on the first lookup and read where each lookup leads, never loaded whole: it is read in blocks of 4 KB, and a Database keeps up to 64 of them (256 KB) for the lookups that follow. An IPv4 address is found in an IPv6 database; an IPv6 address in an IPv4 database finds nothing. A 64- or 128-bit integer too large for PHP is its hexadecimal digits, 0x first.

The database is the application's: it downloads it with its MaxMind account, keeps it up to date — geoipupdate replaces the file, and a new Database reads the new one —, and shows the attribution MaxMind's licence asks for.