Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Locationiq Provider Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

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.

Where to look first

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.

Minimal working call

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));

First use case worth shipping

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).

Laravel wiring

// 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.

Implementation Patterns

Wrap it in a domain service

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();
    }
}

Cache aggressively — the free tier is small

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.

Queue bulk work

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.

Store what you get, in the right shape

$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()

Chain providers for fallback

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.

Test with a mock HTTP client

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.

Gotchas and Tips

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.

  • 401 → misconfigured key or wrong region; alert, don't retry.
  • 429 → back off with jitter, and check whether you forgot caching.
  • 5xx → retry up to 2–3 times, then fall back.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style