Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Geographical Laravel Package

malhal/laravel-geographical

Laravel package that adds geographic query scopes to Eloquent models. Store latitude and longitude, then calculate Haversine distances, filter records within a radius, and order results by proximity using clean, chainable Eloquent methods.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is a thin, opinionated layer over the Haversine formula: it gives you a migration helper for the two coordinate columns, a trait that adds query scopes for distance/ordering/geofencing, and nothing else. There is no geocoding, no map rendering, no spatial index — you bring the coordinates, it does the SQL math.

1. Install and publish config

composer require malhal/laravel-geographical
php artisan vendor:publish --provider="Malhal\Geographical\GeographicalServiceProvider"

This drops a config/geographical.php in place. Read it first — it controls the default unit (kilometres vs miles) and the column names used by the trait. If your schema already uses different column names than latitude / longitude, this is where you reconcile it.

2. Add the columns

Inside a Schema::create() closure:

Schema::create('shops', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->geographical();   // adds latitude + longitude
    $table->timestamps();
});

3. Add the trait

use Malhal\Geographical\Geographical;

class Shop extends Model
{
    use Geographical;
}

4. First useful query

$shops = Shop::distance($lat, $lng)
    ->orderByDistance()
    ->paginate(20);

// each model now carries a computed ->distance attribute (in the configured unit)

Where to look first: the README's scope list, then src/Geographical.php — the entire public API is four or five scope methods and a couple of protected helpers that build the SQL expression. Once you've read that file you know everything the package does.


Implementation Patterns

Nearby search with pagination

This is the 90% use case. Distance is computed in SQL, so it composes with where, paginate, and orderBy the way any other column does — except when it doesn't (see Gotchas).

$results = Shop::query()
    ->where('is_active', true)
    ->distance($lat, $lng)
    ->orderByDistance('asc')
    ->paginate(20);

Because distance is a real selected column, it serialises straight through to your API response. Wrap it in a Resource if you want to round or convert:

class ShopResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id'       => $this->id,
            'name'     => $this->name,
            'distance' => $this->when(isset($this->distance),
                                round($this->distance, 2)),
        ];
    }
}

Store locator / geofence

For "find shops between 5 km and 25 km away", the package exposes a geofence-style scope that avoids the alias-in-WHERE problem:

$ring = Shop::geofence($lat, $lng, 5, 25)->get();

Use this rather than trying to where('distance', '>', 5) — see the alias gotcha below. (Confirm argument order against the README for your installed version; it has been stable but is worth a glance.)

Put the coordinates on the model as a value object

The package only gives you two floats. Normalise them yourself so you never pass a latitude of 120 into the Haversine expression:

protected $casts = [
    'latitude'  => 'float',
    'longitude' => 'float',
];

public function setCoordinatesAttribute(array $pair): void
{
    [$this->latitude, $this->longitude] = $pair;
}

A cast is worth it purely because decimal() returns a string from MySQL — without it, comparisons and JSON output get ugly.

Bounding box first, Haversine second

The single most important pattern for production. Haversine cannot use an index; a cheap rectangle filter can. Apply it before the distance scope:

$latDelta = $radiusKm / 111;
$lngDelta = $radiusKm / (111 * cos(deg2rad($lat)));

$shops = Shop::query()
    ->whereBetween('latitude',  [$lat - $latDelta, $lat + $latDelta])
    ->whereBetween('longitude', [$lng - $lngDelta, $lng + $lngDelta])
    ->distance($lat, $lng)
    ->having('distance', '<=', $radiusKm)   // exact circle
    ->orderByDistance()
    ->get();

Add $table->index(['latitude', 'longitude']) in the migration. It will not help the distance calculation, but it makes the box filter a range scan instead of a full table read.

Reuse across models

The trait is stateless, so apply it to every location-bearing model (Shop, Warehouse, User, Delivery). If you want shared behaviour — a scopeNearby() that combines the box + distance + having chain above — put it in your own trait that wraps Geographical:

trait Nearby
{
    public function scopeNearby($q, float $lat, float $lng, float $km = 25)
    {
        // bounding box + ->distance(...) + ->having(...)
    }
}

Callers then write Shop::nearby($lat, $lng, 10)->get() and never touch the raw scopes. This also gives you one place to swap the implementation out if you later move to MySQL ST_Distance_Sphere or PostGIS.

Testing

The scopes emit MySQL functions (RADIANS, ACOS, COS, SIN). SQLite in-memory — the default test DB for most Laravel suites — will not execute them. Either run those tests against MySQL in CI, or fake at the repository level when unit-testing controllers. Do not let a RefreshDatabase + SQLite suite quietly skip the geo tests; that is where the bugs live.


Gotchas and Tips

The distance alias is not usable in WHERE. MySQL resolves select aliases only in GROUP BY/HAVING/ORDER BY. ->where('distance', '<', 10) after ->distance(...) will either error or silently match nothing. Use ->having('distance', '<', 10), or use the package's geofence scope which builds the expression properly.

Scope order matters. Call distance() before orderByDistance() — ordering references the computed alias. Likewise, a ->select('id', 'name') anywhere in the chain will clobber the distance column, because the scope appends via addSelect onto whatever select exists. If you need to narrow columns, use addSelect semantics or call select() before distance() and accept that only your columns plus distance come back.

Every call is a full table scan on the Haversine expression. With 500 rows nobody notices. With 500k rows the box filter above is the difference between 4 ms and 4 seconds. Do not ship a store locator without it.

$table->geographical() is a macro, not a Blueprint method. It works inside Schema::create(). On an existing table (Schema::table()) behaviour depends on version — if it misbehaves, just declare the columns yourself: $table->decimal('latitude', 10, 8)->nullable() and decimal('longitude', 11, 8)->nullable(). Note the differing precision: ±90 needs one fewer digit than ±180.

Always nullable(). Records created before a location was captured will have NULL coordinates, and NULL poisons the trig functions — the row's distance comes back NULL and it sorts unpredictably. Either make the columns nullable and filter with ->whereNotNull('latitude') when running geo queries, or make them non-nullable and enforce it at the form-request level.

Unit confusion. The config default is kilometres. If the product spec says miles, change it once in config/geographical.php — do not divide by 1.609 in twelve different controllers.

Pagination + having. Laravel's paginate() runs a count(*) on the query. When you've added a having clause, the count can behave differently across MySQL versions (strict modes reject HAVING on non-aggregated queries). Test the full paginated response, not just ->get().

Computed attributes don't survive fresh() or re-saves. $shop->distance exists only on models hydrated by the distance query. If you reload the model, it's gone; isset($this->distance) guarding in your resource is not paranoia.

No PostGIS, no SRID, no spherical index. This is great-circle math on a perfect sphere — fine for "shops within 10 km", wrong for anything survey-grade, and irrelevant if you later need polygon containment. Budget for a migration to ST_Distance_Sphere or a real spatial index if the feature grows beyond radius search.

Extension point: every scope is public and short. If you need a different formula (Vincenty, or a flat-earth approximation for small radii), the cleanest move is not to patch the vendor file but to override the scope in your own trait using parent:: semantics or simply not call it. The package has no hard coupling beyond the migration macro and the trait's column-name config.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect