6.1 KiB
Auto-Geocoding
After every Address save, an observer asks a geocoding service for
latitude / longitude based on the postal fields, then writes the
result back onto the row.
The default driver is Nominatim (OpenStreetMap) — free, no API key
required, but policy-capped
at 1 request per second on the public instance. The package
enforces that cap with a Laravel Cache::lock, so even multiple PHP-FPM
workers / queue runners sharing the same address book never overshoot.
Opt-in. The observer is wired up but the master switch defaults to
false, so upgrading the package doesn't change behaviour for an existing app. SetADDRESSES_GEOCODING_ENABLED=true(and a descriptiveADDRESSES_GEOCODING_USER_AGENT) to turn it on.
Lifecycle
Address::create([...])
│
├─► INSERT row
│
├─► AddressObserver::created
│ │
│ ├─► Cache::lock acquired (cluster-wide)
│ ├─► sleep until min_interval since last call
│ ├─► HTTP GET nominatim/search?…
│ ├─► stamp "last call at" → cache
│ └─► lock released
│
├─► $address->saveQuietly() (lat / lon → DB, no recursion)
│
└─► return $address
Updates work the same way, but the observer only fires the geocoder when a postal field actually changed (street, postal_code, city, state, county, country_code, building, street_extra). Plain latitude/longitude edits — including the observer's own write-back — don't loop, and unrelated columns (notes, meta, has_elevator, …) are ignored.
Configuration
// config/addresses.php
'geocoding' => [
'enabled' => true, // master switch
'driver' => 'nominatim', // only one shipped
'update_only_when_missing' => false, // keep manual coords
'cache_store' => null, // default cache store
'cache_prefix' => 'addresses:geocoding',
'lock_wait_seconds' => 10, // queue depth budget
'lock_ttl_seconds' => 15, // crash-recovery TTL
'min_interval_seconds' => 1.0, // OSMF policy floor
'timeout_seconds' => 8,
'accept_language' => 'en',
'drivers' => [
'nominatim' => [
'endpoint' => 'https://nominatim.openstreetmap.org/search',
'user_agent' => 'your-app-name (you@example.com)',
'email' => 'you@example.com',
],
],
],
Every key reads from a matching env('ADDRESSES_GEOCODING_*') so you
can override per-environment without publishing the config.
Notable env vars
| Variable | Default | Use case |
|---|---|---|
ADDRESSES_GEOCODING_ENABLED |
true |
Turn the whole observer off (CI, bulk imports) |
ADDRESSES_GEOCODING_ONLY_WHEN_MISSING |
false |
Preserve manually-entered coordinates |
ADDRESSES_GEOCODING_MIN_INTERVAL |
1.0 |
Lower than 1 only if you self-host Nominatim |
ADDRESSES_GEOCODING_USER_AGENT |
package default | Set in production — OSMF blocks generic UAs |
ADDRESSES_GEOCODING_EMAIL |
null |
Contact email for OSMF (recommended) |
ADDRESSES_GEOCODING_NOMINATIM_URL |
OSM public endpoint | Point at a self-hosted Nominatim instance |
ADDRESSES_GEOCODING_CACHE_STORE |
app default | Share the lock across processes (redis / memcache) |
Plug in a different provider
The observer talks to the Geocoder contract, not to any concrete
driver. Bind your own implementation in a service provider:
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
use App\Services\MyMapboxGeocoder;
public function register(): void
{
$this->app->singleton(Geocoder::class, MyMapboxGeocoder::class);
}
The contract is:
interface Geocoder
{
public function geocode(Address $address): ?GeocodingResult;
}
GeocodingResult is a small DTO with latitude, longitude,
displayName, and the raw provider payload.
Your driver is responsible for its own concurrency / rate-limit policy
— Google Maps and Mapbox have quota systems server-side, so a custom
driver usually doesn't need a Cache::lock at all.
Failure modes
The observer is forgiving by design — geocoding is best-effort:
| Failure | Behaviour |
|---|---|
Cache::lock wait times out |
Logged (info), the address keeps its current coords. Re-save (or backfill) to retry. |
| Network / 5xx from upstream | Logged (warning), the address keeps its current coords. |
| Upstream returns no match | Silent — no logs, no DB writes. |
| Upstream returns garbage | Silent — invalid lat/lon (NaN, missing, non-numeric) are treated as "no match". |
In every case the user's save() / create() call returns
successfully — geocoding never blows up the consuming code path.
Testing
Drop Http::fake() into your test setup; the geocoder uses Laravel's
HTTP client so it picks up the fakes automatically. To turn the
observer off entirely:
protected function defineEnvironment($app): void
{
$app['config']->set('addresses.geocoding.enabled', false);
}
See tests/Unit/GeocodingTest.php for full examples including the
rate-limit timing assertion.