taldres/laravel-geocode-cache
A lightweight Laravel package for caching geocoding results, letting you store and reuse address lookups via your chosen cache driver to cut repeated API calls, improve response times, and keep third-party usage under control. Configurable and easy to drop into any Laravel app.
Install the package and publish its assets:
composer require taldres/laravel-geocode-cache
php artisan vendor:publish --tag="laravel-geocode-cache-config"
php artisan migrate
The package stores resolved coordinates in a database table so repeated lookups of the same address never hit the geocoding provider twice. Configure your provider credentials in .env (for Google, a key with the Geocoding API enabled and billing active — without billing the API returns REQUEST_DENIED rather than an error the package can interpret usefully), then confirm the values land in config/laravel-geocode-cache.php:
GEOCODE_CACHE_DRIVER=google
GEOCODE_CACHE_API_KEY=your-key
GEOCODE_CACHE_TTL=2592000 # seconds; see Gotchas — Google is clamped
Where to look first, in order:
config/laravel-geocode-cache.php — driver, TTL, table name, and any HTTP client/timeout options. This file answers most day-one questions.decimal or float.First use case: geocode once at write time, read from cache forever after.
// app/Observers/AddressObserver.php
public function saving(Address $address): void
{
if (! $address->isDirty(['street', 'city', 'postal_code', 'country'])) {
return;
}
$result = GeocodeCache::for($address->normalizedLine());
$address->latitude = $result?->lat;
$address->longitude = $result?->lng;
}
That single observer typically removes 90%+ of a small app's geocoding bill, because the cache absorbs repeated lookups of the same address across users and requests.
Geocode on write, never on read. Resolve coordinates when the record is created or its address fields change, store them on your own model, and let your read paths be pure database reads. The cache table then becomes a deduplication layer between different records that share an address, not a per-request lookup. This is the difference between a page that renders in 8ms and one that occasionally renders in 900ms.
Normalize before you cache. The cache is keyed on the string you pass in, so "1 Main St, Springfield", "1 main st, springfield", and "1 Main Street, Springfield, IL" are three separate entries and three paid API calls. Normalize in one place and use it everywhere:
final class AddressNormalizer
{
public static function line(array $parts): string
{
return Str::of(implode(', ', array_filter([
$parts['street'] ?? null,
$parts['city'] ?? null,
$parts['region'] ?? null,
$parts['postal_code'] ?? null,
$parts['country'] ?? null,
])))->squish()->lower()->toString();
}
}
Normalize consistently — if you normalize on write but pass raw input on the fallback lookup, you get cache misses on addresses you already have.
Wrap it in your own service. Bind a thin Geocoder interface in your app that delegates to the package. This gives you three things the package alone cannot: an easy fake for tests, a place to add a null-safe API (the package may return null or throw on zero results — your wrapper should decide which, once), and a swap path if you change providers or drop the package.
final class GeocoderService
{
public function resolve(string $line): ?Coordinates
{
try {
$r = GeocodeCache::for($line);
return $r ? new Coordinates((float) $r->lat, (float) $r->lng) : null;
} catch (GeocodingException $e) {
report($e);
return null; // never let geocoding break a business write
}
}
}
Queue the lookups. Geocoding is a network call with a hard external dependency. For imports and bulk backfills, dispatch a job per record (or chunk) and attach RateLimited middleware — Google's per-second quota will otherwise turn a 5,000-row import into a wall of failures.
#[RateLimited('google-geocode')] // ~40 QPS ceiling, well under the default 50
final class GeocodeAddress implements ShouldQueue { /* ... */ }
Prefer batching where the API allows it. For backfills, resolve the distinct normalized address list first (Address::distinct()->pluck('normalized')), geocode those, then write back with a single upsert. Ten thousand records with 400 unique addresses should cost 400 API calls, not 10,000.
Backfill with a resumable command. Give yourself an artisan command that walks rows where latitude IS NULL, chunks by ID, and is safe to re-run. You will run it more than once — after enabling the package mid-project, after a TTL expiry, after a provider change.
Test with fakes, not the network. Http::fake() against the provider's endpoint is the most robust approach because it does not depend on the package's internal seams staying stable. Assert that the second identical call makes zero HTTP requests — that assertion is the whole value proposition of the package in one test.
v0.2.0 is a breaking change: Google results are now retained for at most 29 days, regardless of your ttl config. The package clamps anything you configure to just under 30 days, which mirrors Google's Terms of Service restriction on caching geocoding results. Nothing in your code changes, but the semantics do. Audit for these before upgrading:
addresses table. If you persist coordinates on your models and treat them as permanent, you are now holding Google-derived data past the ToS window even though the package's own cache expires correctly. Decide explicitly whether your domain data is a derived fact you own (generally fine for a street address → point, and it is what the ToS is actually aimed at preventing you from reselling) or a mirror of the provider's response. This is a legal judgment, not a technical one — get it reviewed if geocoding is core to your product.Treat a TTL you set above 29 days as silently ignored for Google. There is no warning. If you set ttl to 90 days and wonder why the hit rate collapses in month two, this is why. Set it below 29 days deliberately — 7–14 days is usually the right trade for cost versus re-fetch volume.
Config caching will mask your .env edits. php artisan config:cache during deploy means changing GEOCODE_CACHE_API_KEY in .env does nothing until you re-cache. This bites hardest right after a key rotation, when every lookup fails and the config looks correct.
A missing or restricted API key fails at the provider, not at boot. The package has no way to validate your key at install time, so the first failure surfaces on the first real write — often in a queued job, often in staging where the key is absent. Add a startup health check (php artisan geocode:ping or a test that resolves a known address) or you will discover it in production.
Zero-result responses are the ambiguous case. Decide whether "no match" is cached and for how long. Caching a negative result forever means a bad address string is permanently unresolvable after a single typo'd submission; caching it for zero seconds means a malformed input re-queries on every write. Match the negative TTL to the positive one, and log negatives — they are almost always a normalization bug rather than a nonexistent address.
Coordinates are decimal, not float. If you later compute distances with ST_Distance_Sphere, float accumulates drift and a spatial index on a float column will not behave. Verify the published migration uses decimal(10,7); if it does not, add your own copy on the destination model.
Never geocode inside a loop or a collection map. The naive pattern — User::all()->each(fn ($u) => geocode($u)) — is the single most common way this package's cache stops helping: it does nothing for 100 records with 100 distinct addresses, and it will hit per-second quotas. Collect distinct addresses, resolve them once, then join in memory.
Guard the write path. Wrapping the call in a try/catch that returns null and lets the record save without coordinates is nearly always correct; a provider outage should degrade a map to a placeholder, not reject a customer's form submission. Store a geocoded_at timestamp so you can see which records are stale after an upgrade.
Extension points worth checking before you fork anything: container bindings in the service provider (custom geocoder implementing the package contract), the published config's HTTP client options (timeouts, retries, custom User-Agent), and whether the cache table name is configurable — useful when you want a second, non-compliance-bound cache for a provider whose ToS permits longer retention.
How can I help you explore Laravel packages today?