geocoder-php/locationiq-provider
LocationIQ provider for the Geocoder PHP library, adding geocoding and reverse geocoding through LocationIQ's API. Drop it into any Geocoder setup to resolve addresses to coordinates and coordinates back to addresses.
composer require geocoder-php/locationiq-provider guzzlehttp/guzzle
You need three things before writing any code: a LocationIQ API key, the region your key is provisioned on (us1 or eu1), and a PSR-18 HTTP client. The provider itself pulls in the Geocoder\ core namespace as a dependency; if a class like StatefulGeocoder goes missing after install, run composer show geocoder-php/* and add the core package explicitly rather than guessing.
vendor/geocoder-php/locationiq-provider/src/LocationIQ.php — read the constructor signature before you write your service provider. It takes the HTTP client, the API key, then region/locale/limit-style options, and the positional order is the single most common source of "why is my locale being used as a region" bugs. Two minutes there saves an afternoon.
use Geocoder\Provider\LocationIQ\LocationIQ;
use Geocoder\Query\GeocodeQuery;
use Geocoder\Query\ReverseQuery;
use Geocoder\StatefulGeocoder;
use GuzzleHttp\Client as GuzzleClient;
$http = new GuzzleClient(['timeout' => 5.0, 'connect_timeout' => 2.0]);
$provider = new LocationIQ(
$http,
config('services.locationiq.key'),
'us1', // region — must match the account the key belongs to
'en', // locale
5 // default result limit
);
$geocoder = new StatefulGeocoder($provider, 'en');
// address -> coordinates
$results = $geocoder->geocodeQuery(GeocodeQuery::create('1 Infinite Loop, Cupertino, CA'));
if (! $results->isEmpty()) {
$address = $results->first();
[$lat, $lng] = [$address->getCoordinates()->getLatitude(), $address->getCoordinates()->getLongitude()];
}
// coordinates -> address
$reverse = $geocoder->reverseQuery(ReverseQuery::fromCoordinates(37.3318, -122.0312));
Address capture at checkout. Geocode on submit, store lat/lng on the address record, and use the returned getCoordinates() to place a pin on a confirmation map or to compute delivery zones. Keep it synchronous there — it's one call on a user-initiated action. Move everything else to queues (see below).
// config/services.php
'locationiq' => [
'key' => env('LOCATIONIQ_API_KEY'),
'region' => env('LOCATIONIQ_REGION', 'us1'),
],
// app/Providers/GeocoderServiceProvider.php
public function register(): void
{
$this->app->singleton(LocationIQ::class, fn () => new LocationIQ(
new GuzzleClient(['timeout' => 5, 'connect_timeout' => 2]),
config('services.locationiq.key'),
config('services.locationiq.region'),
config('app.locale'),
5,
));
$this->app->singleton(StatefulGeocoder::class, function ($app) {
$aggregator = new ProviderAggregator();
$aggregator->registerProvider($app->make(LocationIQ::class));
return new StatefulGeocoder($aggregator, config('app.locale'));
});
}
Then inject StatefulGeocoder into controllers, jobs, and commands. Bind the geocoder, not the provider, everywhere outside the service provider — it keeps the door open for swapping or chaining providers later.
Never let GeocodeQuery/Collection objects leak into controllers. One thin class converts between your domain and Geocoder's, and gives you one place to catch exceptions, log, and cache.
final class GeocodingService
{
public function __construct(private StatefulGeocoder $geocoder) {}
public function coordinatesFor(string $address): ?Coordinates
{
try {
$results = $this->geocoder->geocodeQuery(
GeocodeQuery::create($address)->withLimit(1)
);
} catch (\Throwable $e) {
report($e);
return null;
}
return $results->isEmpty() ? null : $results->first()->getCoordinates();
}
public function addressFor(float $lat, float $lng): ?Address
{
$results = $this->geocoder->reverseQuery(ReverseQuery::fromCoordinates($lat, $lng));
return $results->isEmpty() ? null : $results->first();
}
}
LocationIQ's free tier is roughly 2 requests/second and a few thousand per day. Two viable approaches:
Laravel-native decorator (recommended — you control the key):
final class CachedProvider implements Provider
{
public function __construct(private Provider $inner) {}
public function geocodeQuery(GeocodeQuery $query): Collection
{
return Cache::remember(
'geo:'.sha1(serialize($query)),
now()->addDays(30),
fn () => $this->inner->geocodeQuery($query)
);
}
public function reverseQuery(ReverseQuery $query): Collection
{
return Cache::remember(
'geor:'.sha1(serialize($query)),
now()->addDays(30),
fn () => $this->inner->reverseQuery($query)
);
}
public function getName(): string { return $this->inner->getName(); }
}
Plug-and-play: Geocoder\Provider\Cache\ProviderCache takes any PSR-16 CacheInterface. Laravel's Cache facade is not PSR-16, so bridge it via symfony/cache's Psr16Cache — or just use the decorator above and skip the extra dependency.
Normalise the input before hashing (mb_strtolower, collapse whitespace) or you'll cache the same address five times with different casing.
Any loop over more than a handful of rows goes to a job. Geocoding a CSV import inline will time out and burn your quota in the retry storm.
final class GeocodeAddress implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public int $addressId) {}
public function handle(GeocodingService $geo): void
{
$address = Address::find($this->addressId);
$coords = $geo->coordinatesFor($address->formatted);
if ($coords) {
$address->update([
'latitude' => $coords->getLatitude(),
'longitude' => $coords->getLongitude(),
'geocoded_at' => now(),
]);
}
}
}
Throttle the queue (->middleware(new RateLimited('locationiq')) with a limiter capped at ~2/s) so workers don't collide with the rate limit. Note the provider is synchronous — concurrency only comes from running more workers, and more workers just means more 429s.
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
$table->string('geocoded_city')->nullable();
$table->string('geocoded_country_code', 2)->nullable();
$table->timestamp('geocoded_at')->nullable();
decimal(10,7) is the standard for lat/lng and avoids the float drift you get from double when comparing or sorting. Pull the pieces out of the Address object once at write time rather than re-parsing at read time:
$address->getCoordinates() // Coordinates
$address->getStreetName()
$address->getStreetNumber()
$address->getPostalCode()
$address->getLocality() // city / town
$address->getCountry()->getCode() // ISO-3166 alpha-2
$address->getAdminLevels()->get(1)
$address->getBounds()
If LocationIQ coming back empty is a business problem, wrap it in a chain rather than writing if/else in your service:
$aggregator = new ProviderAggregator();
$aggregator->registerProvider($locationIQ); // primary
$aggregator->registerProvider($nominatim); // fallback, no key needed
The aggregator queries the first provider and only falls through when nothing is found — not when a request fails. For failure fallback you still need try/catch around the call.
Never hit the live API in the test suite.
use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
$mock = new MockClient();
$mock->addResponse(new Response(200, [], $fixtureXml));
$provider = new LocationIQ($mock, 'test-key');
Record one real response body per scenario (hit, miss, malformed address) and commit them as fixtures. Http\Mock\Client satisfies PSR-18, so it drops straight into the constructor.
Region is not cosmetic. The region string is interpolated into the hostname ({region}.locationiq.com). Use eu1 for a key provisioned in the EU — the wrong region gives you auth errors or needlessly slow responses from the wrong side of the Atlantic. Put it in .env, not in code.
The API key travels in the query string. That means every Guzzle log line, every \Log::debug($url), and every exception message that echoes the request URI can leak it. Don't enable full request logging on this client, and if you must, redact key= with a middleware before it hits the handler.
Empty results throw; they don't return null. Collection::first() raises Geocoder\Exception\CollectionIsEmpty when nothing matched. Check $results->isEmpty() first, or use getFirstOrNull() where available. A typo'd address is not an exception condition — it's normal traffic.
Version pairing bites. Provider 1.4.0 migrated from PHP-HTTP to PSR-18, and 1.5.0 added Geocoder 5 support. If you're pinned to Geocoder 4, stay on 1.4.x. If you have legacy code passing Http\Adapter\Guzzle6\Client, swap it for a plain Guzzle 7 client — Guzzle 7 implements PSR-18 natively, so no adapter wrapper is needed. Run composer why geocoder-php/common (or why geocoder-php/geocoder) when things don't resolve.
normalizecity=1 is load-bearing. The provider appends format=xml and normalizecity=1 to requests. The city field is only reliably present because of that flag — if you fork the provider, proxy it through your own gateway, or hand-roll a request, you'll see results where getLocality() is null and spend an hour confused. Don't strip it.
Responses are parsed from XML, not JSON. The provider requests format=xml. That's why field naming quirks exist. Don't build your own parser against the JSON endpoint and expect identical behaviour.
Rate limits are 429s, not silent. Wrap calls in try/catch (\Throwable) at the service boundary and let the job's $tries + backoff() handle retries. Authentication failures surface as Geocoder\Exception\InvalidCredentials — treat that as a config bug, not something to retry.
Handle 401 / 429 / 5xx differently.
The class is final. You can't extend LocationIQ. That's deliberate — decorate behind Geocoder\Provider\Provider instead. Your decorator lives in App\, survives package upgrades, and can add caching, logging, metrics, or fallbacks without touching vendor code.
Constructor limit vs query limit. The constructor sets a default cap on results. GeocodeQuery::create(...)->withLimit(1) overrides per call. For a "just give me the pin" lookup, always pass withLimit(1) — you're paying for the bytes you parse.
Locale: two places, one winner. StatefulGeocoder applies its locale when the query doesn't specify one; GeocodeQuery::withLocale('de') wins when set. Set the default on the geocoder from config('app.locale') and override only for specific features (e.g., a German-language invoice address).
Don't geocode inside a DB transaction. A 5-second network timeout holds row locks for 5 seconds. Resolve coordinates first, then write.
Budget for attribution. LocationIQ geocodes OpenStreetMap data. If you render the results on a map, OSM attribution is required — easy to forget when you're focused on the API integration, awkward to retrofit into a shipped UI.
Extension points worth knowing. GeocodeQuery is immutable with with*() builders; Collection is iterable and countable; Address subclasses (AdminLevel, Country, Coordinates, Bounds) all have accessors. If you need a field the provider doesn't map, read the raw response through a custom Psr18 middleware rather than patching the provider.
How can I help you explore Laravel packages today?