Distance-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:
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
use Illuminate\Database\Eloquent\Builder;
/**
* Scope a query to include only records within a given radius.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param float|int $distance
* @param float $lat
* @param float $lng
* @param string $units
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeDistance(Builder $query, float|int $distance, float $lat, float $lng, string $units = 'kilometers')
{
if (!$distance || !$lat || !$lng) {
return $query; // nothing to filter
}
$radius = $units === 'miles' ? 3959 : 6371; // Earth radius
// Haversine formula (placeholders only)
$haversine = "(
? * ACOS(
COS(RADIANS(?)) *
COS(RADIANS(coordinates.latitude)) *
COS(RADIANS(coordinates.longitude) - RADIANS(?)) +
SIN(RADIANS(?)) *
SIN(RADIANS(coordinates.latitude))
)
)";
$bindings = [$radius, $lat, $lng, $lat];
return $query->whereHas('coordinates', fn ($q) =>
$q->selectRaw("ROUND($haversine, 2) AS distance", $bindings)
->having('distance', '<=', $distance)
->orderBy('distance', 'asc')
);
}
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
$nearbyCars = Trip::byDistance(
distance: 10, // 10 km radius
lat: $trip->pickup_lat,
lng: $trip->pickup_lng,
units: 'kilometers'
)
->with('coordinates') // eager-load matches
->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.
$query->selectRaw($haversine)
->having('distance', '<=', $distance)
->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
test('returns coordinates within radius', function () {
$origin = ['lat' => 10.5000, 'lng' => -66.9167];
Coordinate::factory()->create(['latitude' => 10.51, 'longitude' => -66.91]); // ~1 km
Coordinate::factory()->create(['latitude' => 11.00, 'longitude' => -67.00]); // ~70 km
$results = Trip::byDistance(5, $origin['lat'], $origin['lng'])->get();
expect($results)->toHaveCount(1);
});
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!.
Top comments (10)
Please don't copy the scope, it is open for sql injection.
sprintf
will not protect against sql injection.%s
allows any string.SelectRaw will prevent sql injection if you use it as it was intended.
You're right, but this post is meant to focus more on location than security. It's not that security is unimportant, but rather that, at this point, security controls should already be in place. For example, when we're discussing a scope, it's assumed that it has already passed through a controller of some kind. This controller should have included prior validation to ensure the latitude and longitude are valid coordinate numbers.
I understand that it isn't the focus.
The more validation that can be added, without making it insane, the better. In case of the example just add type hinting, and it is ok to use
DB::raw
.When there are stings use the raw method, I think it is one of the best things the Laravel database library provides.
You are absolutely correct. That's why I've modified the function and improved a couple of other things. Thank you very much for taking the time to comment.
Now I go into pet peeve territory, I would also use an enum for the unit type. But that is my obsession to eliminate as much strings as possible.
Totally feel you, magic strings make my eye twitch too 😄. But for a quick blog-level snippet I’d keep it lean; diving deeper would drag in extras like: a GeoPoint value object, bounding-box prefilter, DB-native spatial funcs, virtual-indexed distance, radius-aware DistanceUnit enum, reusable trait/macro, strict typed signature + named args, fail-fast guards, Pest tests, and maybe a tiny cache layer. Fun stuff, just overkill for this post!
Any reason why you prefer this rather than the POINT + SPATIAL INDEX you suggest in the "performance power-ups" ?
In my experience it's faster and easier to create a
POINT
column and useST_DistanceSphere
(also if you have some polygons and want to find points in a polygon you can do that too). It just means you need to add a clever attribute on your classes if you still want to display or edit "latitude" and "longitude" manually in your application, but I saw a dramatic improvement in search speed whan I switched to this.I don’t favor that method specifically; I just presented an alternative for those who want to use the Haversine formula. I haven’t personally tried ST_DistanceSphere yet, but I know it’s available and (as you noted) very fast 😊
Love how concise and DB-agnostic you made this! Have you tried switching to spatial columns and native distance queries for even more perf in production?
Hello @dotallio, I haven't personally had the opportunity to test that approach, but I'm aware that it exists and can be faster. When I had the chance to try the Haversine formula, I chose that method because it seemed more intuitive to me.