jcf/geocode
Laravel package that automatically geocodes Eloquent models. Add a trait, define address fields, and jcf/geocode resolves latitude and longitude via Google Maps on save, with caching and configurable API credentials for location-aware apps and maps.
Install and wire up
composer require jcf/geocode
php artisan vendor:publish --tag=geocode # or --provider="Jcf\Geocode\GeocodeServiceProvider"
The package is auto-discovered, so you get a service provider plus a facade alias out of the box. Your first three things to read, in order:
config/geocode.php after publishing — this is where the API key and any default language/region/components live. The exact env var name is defined here; set it in .env (GOOGLE_MAPS_API_KEY or similar) and in config/services.php if you prefer to centralise credentials.src/Facades/...) — it's a thin proxy, so the real public API is whatever the underlying class exposes. Confirm the exact method names for forward and reverse geocoding before you write anything.results[] with geometry.location.lat/lng, formatted_address, place_id, types, and location_type.One prerequisite that trips everyone up: the key must be a server-side key with the Geocoding API enabled and billing active on the Google Cloud project. A browser/JS key will fail with REQUEST_DENIED and no useful message from the package.
First use case — the one you'll actually ship this week: an address input on a "find a store near me" page. Geocode the submitted address once, persist lat/lng alongside the address, then query your own stores table by distance. Do not geocode on every page view.
// routes/web.php or a controller
$point = \Geocode::geocode($request->validated('address')); // confirm method name
if (blank($point)) {
return back()->withErrors(['address' => 'We could not locate that address.']);
}
Add decimal(10,7) columns for latitude/longitude and an index on them before you write the geocoding code — retrofitting the schema after you have rows is the annoying part.
1. Hide the package behind your own interface. Adoption is low (0 dependents), so treat it as a swappable transport, not a foundation.
namespace App\Geo;
interface Geocoder
{
public function forward(string $address): ?Coordinates; // address -> lat/lng
public function reverse(float $lat, float $lng): ?Address; // lat/lng -> address
}
Write one adapter class that calls the facade and maps the raw Google array into your own Coordinates/Address value objects. Everything else in the app depends on the interface. Swapping to Http::get('https://maps.googleapis.com/maps/api/geocode/json', ...) later becomes a one-file change.
2. Cache aggressively, keyed on a normalised address.
public function forward(string $address): ?Coordinates
{
$key = 'geo:fwd:'.md5(mb_strtolower(preg_replace('/\s+/', ' ', trim($address))));
return Cache::remember($key, now()->addDays(30), fn () => $this->callGoogle($address));
}
Normalising whitespace and case before hashing is what stops "10 Downing St" and "10 downing st " from burning two API calls. Cache negative results too, but for hours rather than days — typos are usually fixed within the same session.
3. Reverse geocode the browser's coordinates, not an address. The navigator.geolocation flow gives you lat/lng for free; reverse geocoding is how you turn that into "you're near Camden" for a heading. Round to 5–6 decimals before caching so nearby users share cache entries.
4. Model observer / saving hook for derived coordinates.
static::saving(function (self $model) {
if ($model->isDirty('address') || blank($model->latitude)) {
$point = app(Geocoder::class)->forward($model->address);
$model->latitude = $point?->lat;
$model->longitude = $point?->lng;
}
});
Keep it out of the request cycle for bulk work — see below.
5. Everything batch goes through a queued job. CSV imports and backfills will blow through quota and time out the request. One job per chunk of ~50, with ->delay() or a rate limiter between them, and $tries = 3 with a long backoff.
6. Proximity queries stay in SQL. Once you have coordinates, don't call Google again to "sort by distance" — use a bounding-box prefilter plus Haversine:
$rows = Store::query()
->whereBetween('latitude', [$lat - $dLat, $lat + $dLat])
->whereBetween('longitude', [$lng - $dLng, $lng + $dLng])
->selectRaw('*, (6371 * acos(cos(radians(?)) * cos(radians(latitude))
* cos(radians(longitude) - radians(?)) + sin(radians(?))
* sin(radians(latitude)))) AS distance_km', [$lat, $lng, $lat])
->orderBy('distance_km')
->limit(20)
->get();
7. Testing. Never hit Google in tests. Bind a fake implementation of your Geocoder interface in the container, or if you must exercise the adapter itself, use Http::fake() and return a fixture JSON captured from a real successful response. Keep one fixture per interesting case: OK with multiple results, ZERO_RESULTS, and an error payload.
8. Store the place_id. It's the most durable identifier Google gives you and is the cheapest thing to re-resolve later if you need fresh coordinates.
ZERO_RESULTS is not an exception. An unmatched address comes back as a normal, empty result. Guard every call site with a null/empty check and a user-facing message, or you'll ship null into your distance maths and get 0.0 distances everywhere.OVER_QUERY_LIMIT / REQUEST_DENIED are the ones that bite. They typically surface as a generic exception with a status buried in the payload. Log the whole response body on failure — the Google error_message field is what tells you whether it's billing, an unenabled API, or a missing key restriction.location.lat and location.lng as strings in a nested array. Cast to (float) once, in one place, and never index by [0]/[1].components=country:GB|administrative_area:... filter (or region/language defaults in the config) for user-facing lookups. Also check geometry.location_type — prefer ROOFTOP/RANGE_INTERPOLATED and treat APPROXIMATE as low-confidence.place_id is the exception and may be stored indefinitely. If your app is a "store addresses forever" design, cache coordinates for 30 days and re-resolve from the stored place_id on expiry.usleep() between calls in any synchronous loop. A foreach over 200 addresses looks fine in dev and trips OVER_QUERY_LIMIT instantly in production.composer.json and keep a Http:: fallback adapter in the same PR. The Google Geocoding REST endpoint is two query parameters and a JSON decode — the fallback is genuinely 20 lines.GEOCODE_API_KEY, run php artisan config:clear; a stale bootstrap/cache/config.php is the usual cause of "it works in tinker but not the web app."How can I help you explore Laravel packages today?