Skip to content

Geocoding

phil/geo-bundle
phil_geo:
    geocoding:
        providers: [nrcan, nominatim, google_maps]   # asked in this order
        nominatim:
            user_agent: 'Example (contact@example.com)'
            base_url: 'https://nominatim.openstreetmap.org'
            limit: 5
        google_maps:
            api_key: '%env(GOOGLE_MAPS_KEY)%'
        nrcan:
            limit: 5
        photon:
            base_url: 'https://photon.komoot.io'
            languages: [de, en, fr]
        opencage:
            api_key: '%env(OPENCAGE_KEY)%'
        here:
            api_key: '%env(HERE_KEY)%'
        mapbox:
            access_token: '%env(MAPBOX_TOKEN)%'
            permanent: false        # true: answers that may be kept, billed
        azure_maps:
            subscription_key: '%env(AZURE_MAPS_KEY)%'
        adresses_quebec:
            limit: 5
        cache:
            pool: cache.app
            lifetime: 604800
    ip_locator:
        maxmind:
            database: '%kernel.project_dir%/var/geoip/GeoLite2-City.mmdb'
            locale: fr
            account_id: '%env(MAXMIND_ACCOUNT_ID)%'
            license_key: '%env(MAXMIND_LICENSE_KEY)%'
        ipstack:
            access_key: '%env(IPSTACK_KEY)%'
OptionDefaultRole
geocoding.providersnonenominatim, google_maps, nrcan, photon, opencage, here, mapbox, azure_maps, adresses_quebec; none, and no Geocoder is registered
geocoding.nominatim.user_agentRequired with Nominatim: the site and how to reach it
geocoding.nominatim.base_urlthe public serverAnother server, one's own
geocoding.nominatim.limit5The most places an address gives, 1 to 40
geocoding.google_maps.api_keyRequired with Google Maps
geocoding.google_maps.base_urlthe Geocoding API
geocoding.nrcan.base_urlthe service of Natural Resources Canada
geocoding.nrcan.limit5The most places an address gives, 1 to 40
geocoding.photon.base_urlthe public serverOne's own server
geocoding.photon.limit5The most places an address gives, 1 to 40
geocoding.photon.languages[de, en, fr]The languages the server knows; a locale in another is not sent
geocoding.opencage.api_keyRequired with OpenCage
geocoding.opencage.base_url, limitits API, 5The most places an address gives, 1 to 100
geocoding.here.api_keyRequired with HERE
geocoding.here.geocode_url, reverse_url, limitits API, 5The most places, 1 to 100
geocoding.mapbox.access_tokenRequired with Mapbox
geocoding.mapbox.base_url, limitthe v6 API, 5The most places an address gives, 1 to 10
geocoding.mapbox.permanentfalseAnswers that may be kept, which Mapbox bills; temporary ones may not be cached
geocoding.azure_maps.subscription_keyRequired with Azure Maps
geocoding.azure_maps.base_url, limit, api_versionits API, 5, 2026-01-01The most places an address gives, 1 to 20
geocoding.adresses_quebec.base_url, limitthe service of the Gouvernement du Québec, 5The most places an address gives, 1 to 40
geocoding.cache.poolnoneA cache pool that keeps the answers
geocoding.cache.lifetime604800Seconds an answer is kept, a week
ip_locator.maxmind.databasenoneThe path of a MaxMind city database: registers it as the IpLocator, before ipstack
ip_locator.maxmind.localeenThe language of the names, English when the database has none in it
ip_locator.maxmind.account_id, license_keynoneThe MaxMind account phil:geo:maxmind:update downloads with: both or neither, and only with a database; together they register the command
ip_locator.maxmind.editionGeoLite2-CityThe edition downloaded: GeoIP2-City on a paid account
ip_locator.maxmind.download_urlMaxMind's
ip_locator.ipstack.access_keynoneRegisters the ipstack locator
ip_locator.ipstack.base_urlhttps://api.ipstack.comhttp://api.ipstack.com on a plan that answers over plain HTTP only, which sends the visitor's IP address and the access key in clear

One provider is the Geocoder; several are a ChainGeocoder, asked in their order; with a pool, the whole is a CachedGeocoder. Each adapter takes the http_client service of FrameworkBundle. A pool keeps every provider's answers, so the configuration refuses a pool when the providers include Mapbox without permanent: its terms forbid keeping its temporary answers. Keep a lifetime under thirty days with HERE (the terms of each).

Id
phil_geo.geocoder.{provider} — nominatim, google_maps, nrcan, photon, opencage, here, mapbox, azure_maps, adresses_quebeceach provider listed
phil_geo.geocoder.chainwith several providers
phil_geo.geocoder.cachedwith a pool
Phil\Geo\Geocoding\Geocoderalias of the last of them
phil_geo.ip_locator.ipstackwith an access key; aliased as Phil\Geo\Geocoding\IpLocator when no MaxMind database is named
phil_geo.maxmind.database, alias Phil\Geo\Geocoding\MaxMind\Databasewith a MaxMind database
phil_geo.ip_locator.maxmind, alias Phil\Geo\Geocoding\IpLocatorwith a MaxMind database
phil_geo.maxmind.updater, phil_geo.maxmind.update_commandwith a MaxMind database, account_id and license_key

The MaxMind database comes first: an address looked up on the server never leaves it. The file is the application's — kept up to date by phil:geo:maxmind:update or by geoipupdate —, and the path need not exist when the container is built: it is opened on the first lookup. Reading it needs no HTTP client: without account_id and license_key, nothing that downloads is registered, and the application may go without symfony/http-client. ipstack's locator is registered with its access key even beside a database, for whoever asks for it by its id.

public function __construct(private Geocoder $geocoder) {}

$places = $this->geocoder->geocode($address, $request->getLocale());

The places, the failures and the providers are phil/geo's: see its geocoding document.

Keeping the MaxMind database up to date

bin/console phil:geo:maxmind:update           # downloads only when MaxMind has a newer database
bin/console phil:geo:maxmind:update --force   # downloads it anyway

The command is registered with a MaxMind database, account_id, license_key and symfony/console; it asks MaxMind through the http_client service. It asks MaxMind for the SHA-256 of the latest archive of the edition, with the account and the licence key; when it is the checksum kept beside the database, in GeoLite2-City.mmdb.sha256, nothing is downloaded. Otherwise it:

  1. downloads the archive next to the database, under a name of its own, as it arrives;
  2. checks it against that checksum;
  3. reads GeoLite2-City.mmdb out of it, block by block, never the whole archive in memory;
  4. opens it, to make sure it is a MaxMind database of the edition asked for;
  5. renames it over the old one, in one step, and keeps its checksum.

Any failure — an account refused, an archive that does not match, a file that is not the edition — leaves the database in place and its temporary files removed, and the command fails with the reason. One update runs at a time: it holds a lock on GeoLite2-City.mmdb.lock, left beside the database, and a second run — a cron and a forced update, two hosts on shared storage — fails at once without touching the first one's files. Run it daily, as MaxMind publishes GeoLite2 twice a week:

0 4 * * * cd /var/www/site && bin/console phil:geo:maxmind:update --no-interaction

A web request opens the database on its first lookup, so it reads the new file at once. A long-running worker — a Messenger consumer, FrankenPHP in worker mode — keeps the file it opened until it restarts.