akuechler/laravel-geoly
Laravel GeoLy adds geospatial querying to Eloquent, letting you store coordinates and run distance-based searches, radius filters, and nearby lookups directly in your models without extra services.
Install and publish
composer require akuechler/laravel-geoly
php artisan vendor:publish --provider="Akuechler\Geoly\GeolyServiceProvider"
You get a config file that controls the geohash column name and the precision used when encoding. Read that file first — it is the only knob that materially changes query behaviour, and every other gotcha in this package traces back to it.
Schema and model
The package only stores a geohash string; you keep your own latitude / longitude columns. Add the column via a normal migration:
Schema::table('locations', function (Blueprint $table) {
$table->string('geohash', 12)->nullable()->index();
});
Then put the package trait on the model (check src/Traits for the exact name — the trait + the query scope are the entire public API, so verify both against the README before writing code):
use Akuechler\Geoly\Traits\Geofenceable; // confirm name
class Location extends Model
{
use Geofenceable;
protected $fillable = ['name', 'latitude', 'longitude', 'geohash'];
}
First use case: "nearest stores to this address"
Backfill the column once (see below), then:
$nearby = Location::geofence($lat, $lng, 10) // radius in the unit the config/scope expects
->orderBy('name')
->get();
If that returns rows, everything else is refinement. If it returns nothing, your geohash column is almost certainly null or was encoded at the wrong precision — that's the failure mode 90% of the time.
Backfill before you ship anything
Location::whereNull('geohash')->chunkById(500, function ($rows) {
foreach ($rows as $row) {
$row->geohash = Geohash::encode($row->latitude, $row->longitude); // confirm helper class
$row->saveQuietly();
}
});
Keep the geohash derived, never hand-authored
Treat geohash as a computed column. The cleanest pattern is a model observer or a saving hook that re-encodes whenever lat/lng change:
protected static function booted(): void
{
static::saving(function (self $model) {
if ($model->isDirty(['latitude', 'longitude']) || ! $model->geohash) {
$model->geohash = Geohash::encode($model->latitude, $model->longitude);
}
});
}
This is the single highest-value pattern with this package. The scope does not compute geohashes on the fly — it trusts the column — so a stale hash silently drops records out of results with no error.
Use it as a coarse pre-filter, then refine for real distance
Geohash prefix matching gives you a bounding box of cells, not a circle. The package's neighbour-cell expansion gets you close, but if the consumer sees a distance, compute it properly:
$results = Location::geofence($lat, $lng, $radius)
->select('*')
->selectRaw(
'(6371 * acos(cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude)))) AS distance_km',
[$lat, $lng, $lat]
)
->having('distance_km', '<=', $radius)
->orderBy('distance_km')
->get();
This is the pattern worth standardising in a repository/service method — "fast filter in SQL, exact maths in SQL, sort in SQL" — so controllers never see raw geohash logic.
Precision is a per-radius decision
Geohash characters buy precision: 4 chars ≈ 20 km, 5 ≈ 2.4 km, 6 ≈ 600 m, 7 ≈ 75 m. A hash encoded at precision 7 will never match a query that computes prefixes at precision 5 — LIKE is directional. Pick one precision globally via config unless you have a measured reason not to, and document it. If you need to support "within 50 km" and "within 200 m" against the same data, store the longer hash and use a LEFT(geohash, n) / prefix index rather than two columns.
Compose with existing scopes, and keep filters after the geofence call
Location::geofence($lat, $lng, 5)
->where('is_active', true)
->whereIn('category_id', $categories)
->paginate(20);
Because geohash columns are low-cardinality in aggregate, pair the index with your real filters: a composite index on (geohash, is_active) typically outperforms a standalone geohash index on any table of real size. Verify with EXPLAIN — this is the whole reason to use the package over ST_Distance, so don't leave the index to chance.
API surface
Return the computed distance_km in a resource, not the raw geohash. The geohash is an implementation detail; leaking it invites clients to filter on it and couples them to your precision choice.
The package is unmaintained since April 2021. Before adopting, check composer.json constraints against your Laravel/PHP version. Laravel 9+ and PHP 8.1+ are the likely friction points, and $table->.../trait signatures are trivial to patch if needed. Budget for vendoring or forking the ~few-hundred-line source rather than assuming a fix will land. For a 41-star package, treat it as "copy the algorithm in" rather than "depend on it" if the geofence logic is core to your product.
Stale hashes fail silently. There is no validation that geohash corresponds to latitude/longitude. A manual DB edit, an import script that writes lat/lng directly, or updateQuietly on columns bypassing your observer all corrupt results without throwing. Add an artisan command that reports mismatches (Geohash::encode($lat,$lng) !== $geohash) and run it after every bulk import.
Radius vs. precision mismatch drops edge results. If the query radius is larger than a single cell at your stored precision, you need the neighbour-cell expansion — if the version you install does not do this, you will silently miss records just across a cell boundary. Test explicitly: seed points at ~0 m, ~radius, and ~radius + 10% in all four compass directions from a query centre and assert the counts.
Poles and the antimeridian. Geohash neighbours wrap incorrectly near ±180° longitude and behave oddly above ~85° latitude. If you have any users there, clamp or fall back to a plain bounding-box query.
Unit confusion. The scope's radius unit is whatever the package author chose — read the signature, don't assume kilometres. Write a test with a known distance (e.g. two points 1 km apart) and assert inclusion/exclusion at the boundary; this pins the unit and the precision contract at once.
SQLite in tests. Prefix LIKE queries work fine under SQLite, so your test suite is representative for correctness — but not for performance. Keep a separate EXPLAIN check against MySQL/Postgres, since geohash selectivity is the package's only selling point.
Extension points are thin, by design. You get the config file and the scope. If you need custom behaviour (per-request precision, multiple hash columns, distance-aware pagination), override the scope in your base model rather than patching vendor code — this keeps you upgradeable if a fork ever appears.
How can I help you explore Laravel packages today?