DEV Community

Cover image for Server-Side Geolocation Filtering in Laravel with the Haversine Formula
Eduar Bastidas
Eduar Bastidas

Posted on • Originally published at mreduar.dev

Server-Side Geolocation Filtering in Laravel with the Haversine Formula

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:

Formula on my blog

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 and longitude 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')
    );
}
Enter fullscreen mode Exit fullscreen mode

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 pull Trip rows that have at least one qualifying Coordinate.

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();
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xwero profile image
david duymelinck • Edited

Please don't copy the scope, it is open for sql injection.

  1. the scope arguments should be type hinted. Adding the type in the comments is not enough.
  2. sprintf will not protect against sql injection. %s allows any string.

SelectRaw will prevent sql injection if you use it as it was intended.

Collapse
 
mreduar profile image
Eduar Bastidas

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.

Collapse
 
xwero profile image
david duymelinck

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.

Thread Thread
 
mreduar profile image
Eduar Bastidas

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.

Thread Thread
 
xwero profile image
david duymelinck

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.

Thread Thread
 
mreduar profile image
Eduar Bastidas

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!

Collapse
 
nicolus profile image
Nicolus • Edited

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 use ST_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.

Collapse
 
mreduar profile image
Eduar Bastidas

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 😊

Collapse
 
dotallio profile image
Dotallio

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?

Collapse
 
mreduar profile image
Eduar Bastidas

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.