
Server-Side Geolocation Filtering in Laravel with the Haversine Formula
Eduar Bastidas • July 5, 2025
tipsDistance-aware queries are a core feature for modern apps—whether you're matching riders and drivers, showing events around a user, or surfacing the nearest warehouses for same-day delivery. The fastest, most accurate way to deliver those results is to compute great-circle distance inside your SQL engine with the Haversine formula, then let Eloquent give you a fluent, testable API.
Why Haversine?
Mathematically sound. Haversine treats Earth as a sphere, producing realistic distances at planetary scale without the overhead of full ellipsoidal calculations.
Pushes work to the DB. The heavy trig runs where your data already lives, slicing result sets before PHP ever sees them.
Vendor-agnostic. Works in MySQL, MariaDB, Postgres, SQL Server—anything that supports basic trig functions.
The Math — Haversine Engine, No Mystery
The Haversine formula gives the great-circle distance between two points on a sphere:
Where:
- is the Earth's radius (≈ or )
- is the difference in latitude (radians)
- is the difference in longitude (radians)
- are the latitudes of the two points
- are the longitudes of the two points
Plugging those values in returns the shortest surface distance (the great-circle distance)—ideal for any geospatial filter you need on the server.
The Data Model
We'll generalise first and then pivot to a concrete example:
Table | Purpose | Key Columns |
---|---|---|
trips (or any parent entity) |
The object you're filtering from | id , … |
coordinates |
Latitude/longitude pairs representing nearby entities (cars, stores, users, etc.) | id , latitude , longitude , trip_id |
Trip
➜ hasMany ➜ Coordinate
Coordinate
➜ belongsTo ➜ Trip
You can just as easily embed
latitude
andlongitude
directly on the primary model. The scope below works in either scenario; choosing a relation simply keeps the example laser-focused on nearest cars for a given trip.
The Scope
1/** 2 * Scope a query to include only records within a given radius. 3 * 4 * @param \Illuminate\Database\Eloquent\Builder $query 5 * @param float|int $distance 6 * @param float $lat 7 * @param float $lng 8 * @param string $units 9 * @return \Illuminate\Database\Eloquent\Builder10 */11public static function scopeByDistance(12 $query,13 $distance,14 $lat,15 $lng,16 string $units = 'kilometers'17) {18 $query->when($distance && $lat && $lng, function ($query) use ($lat, $lng, $units, $distance) {19 // Decide Earth-radius constant20 $greatCircleRadius = match ($units) {21 'miles' => 3959, // mi22 'kilometers', default => 6371, // km23 };24 25 // Haversine select expression26 $haversine = sprintf(27 'ROUND(( %d * ACOS( COS( RADIANS(%s) ) ' .28 '* COS( RADIANS( latitude ) ) ' .29 '* COS( RADIANS( longitude ) - RADIANS(%s) ) ' .30 '+ SIN( RADIANS(%s) ) * SIN( RADIANS( latitude ) ) ) ), 2) AS distance',31 $greatCircleRadius,32 $lat,33 $lng,34 $lat35 );36 37 // Filter through the coordinates relation38 $query->whereHas('coordinates', fn ($coordinate) => $coordinate39 ->select(DB::raw($haversine))40 ->having('distance', '<=', $distance)41 ->orderBy('distance', 'ASC')42 );43 });44 45 return $query;46}
Decisive details:
- Units are explicit. No hidden constants—callers pass
'miles'
or'kilometers'
. - Select + having. We compute distance and filter in one trip to the database.
- Relation-aware.
whereHas
ensures we only pullTrip
rows that have at least one qualifyingCoordinate
.
Practical Example: Finding Cars Near a Trip
1$nearbyCars = Trip::byDistance(2 distance: 10, // 10 km radius3 lat: $trip->pickup_lat,4 lng: $trip->pickup_lng,5 units: 'kilometers'6 )7 ->with('coordinates') // eager-load matches8 ->get();
Swap Trip
and Coordinate
for any other domain pair—warehouses and parcels, events and attendees, sellers and buyers.
If You Store Lat/Lng on the Same Table
Just remove the whereHas
wrapper and call selectRaw
/ having
directly on $query
. Everything else remains identical.
1$query->selectRaw($haversine)2 ->having('distance', '<=', $distance)3 ->orderBy('distance');
Performance Power-Ups
Technique | Why It Matters |
---|---|
Composite index on (latitude, longitude) |
Accelerates simple bounding-box prefilters. |
Bounding box guard-clause | whereBetween lat/lng to skip obvious misses before running trig. |
Spatial columns | POINT + SPATIAL INDEX (MySQL) or PostGIS geography types let you switch to ST_DistanceSphere later with a one-liner. |
Query caching | City-scale apps see repetitive origins—cache JSON responses for 30–60 s. |
Testing the Scope
1test('returns coordinates within radius', function () { 2 $origin = ['lat' => 10.5000, 'lng' => -66.9167]; 3 4 Coordinate::factory()->create(['latitude' => 10.51, 'longitude' => -66.91]); // ~1 km 5 Coordinate::factory()->create(['latitude' => 11.00, 'longitude' => -67.00]); // ~70 km 6 7 $results = Trip::byDistance(5, $origin['lat'], $origin['lng'])->get(); 8 9 expect($results)->toHaveCount(1);10});
Fast, deterministic, no external APIs.
Final Thoughts
The Haversine formula is universally applicable—anywhere you need "X within Y km". By embedding it in a concise Eloquent scope, you gain:
- Zero vendor lock-in. Works the same across MySQL, MariaDB, Postgres, or SQL Server.
- Uncompromising performance. The database filters; PHP just maps results to resources.
- Readable, testable code. Your controllers stay slim, your models self-document intent.
Copy the scope, adjust the relationship (or not), and ship precise geospatial queries!.