Search by

zarbinco / laravel-nearby

zarbinco

Geographic distance calculations and Eloquent proximity queries for Laravel.

Package info

github.com/zarbinco/Laravel-Nearby

pkg:composer/zarbinco/laravel-nearby

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

This package is auto-updated.

Last update: 2026-11-02 11:04:27 UTC


README

Latest Stable Version Total Downloads License Tests

Laravel Nearby adds geographic proximity queries to existing Eloquent models and converts distances between common units. Use your model's latitude and longitude columns to filter a map viewport, find nearby records, search within a radius, and order results by distance.

Its calculation core also works through Composer autoloading alone, without Laravel bootstrapping, a database connection, or an external service.

Features

  • Immutable coordinates with explicit validation and spherical geographic distance calculations.
  • Immutable viewport bounds with inclusive edges, wrapped longitudes, and standalone containment checks.
  • Grouped viewport reports with explicit SQL projections, counts, and HAVING filters.
  • Six distance units with unrounded conversions and overflow validation.
  • Eloquent nearest, radius, distance-band, and distance-ordering queries using existing numeric columns.
  • Read-only calculated distances, normal pagination, and configurable coordinate column names.
  • Conservative bounding-box filtering followed by the exact Haversine condition, with MySQL connection and reconnection validation.

Requirements

  • PHP 8.3 or later within the PHP 8 series.
  • Laravel 13.x, with 13.0 as the minimum version, for Eloquent integration. Laravel 12 and earlier, including older standalone Illuminate components, are rejected by Composer.
  • MySQL 8.4.x and the PDO MySQL extension for proximity queries.

The package requires illuminate/database:^13.0; Laravel 13 satisfies that dependency through its normal Composer replacement mechanism. The full Laravel framework is a development dependency through Orchestra Testbench, rather than a runtime requirement. Standalone calculations do not require an application or database connection.

CI is configured for standalone tests, real MySQL integration tests, and separate latest/lowest Laravel 13 consumer installations on PHP 8.3, 8.4, and 8.5. Each workflow run shares one resolved development lockfile across its package test jobs; consumer applications resolve their own runtime dependencies. Hosted workflow results are separate from local verification.

Local package tests have run on PHP 8.3.30, 8.4.27, and 8.5.12 with Laravel 13.39.0 and MySQL 8.4.3. Separate consumer applications have verified Laravel 13.30.0 and 13.39.0 without Testbench. These are verified versions, rather than a claim that every Laravel 13 release was tested. Exact Laravel 13.0.0 remains unverified because Composer blocks it for framework security advisories; verification never disables that protection. Other Laravel major versions and database engines are unsupported.

Installation

Install the package from Packagist with Composer:

composer require zarbinco/laravel-nearby

To request the stable 1.x series explicitly:

composer require "zarbinco/laravel-nearby:^1.0"

These commands require a compatible stable version to be published and indexed on Packagist. Check the package listing for available versions.

No package migration, configuration publishing, service-provider registration, or additional environment variables are required. Eloquent queries use your application's existing MySQL connection and numeric coordinate columns. Local checkout installation is available for package development.

Testing Unreleased Changes

Developers testing unreleased changes can request the development branch explicitly:

composer require "zarbinco/laravel-nearby:dev-main@dev"

This does not change your project's global stability setting. Development branches can change between installations.

Quick Start

Save this as a PHP file in the root of a project where the package is installed:

<?php

declare(strict_types=1);

use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Geo;
use Zarbinco\Nearby\Unit;

require __DIR__.'/vendor/autoload.php';

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);
$destination = new Coordinate(latitude: 35.8400, longitude: 50.9391);

$distance = Geo::distance($origin, $destination);

$kilometers = $distance->in(Unit::Kilometer);
$miles = $distance->in(Unit::Mile);

printf("%.3f km (%.3f mi)\n", $kilometers, $miles);

This example displays 43.920 km (27.291 mi).

Eloquent Models

Add HasNearbyCoordinates to an existing model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Zarbinco\Nearby\Eloquent\Concerns\HasNearbyCoordinates;

class Branch extends Model
{
    use HasNearbyCoordinates;
}

The defaults are latitude and longitude, stored in decimal degrees in ordinary numeric DOUBLE or DECIMAL columns. Existing columns can be used directly; the package does not require migrations, additional tables, or spatial columns. Your model keeps its usual table name, primary key, casts, relationships, global scopes, and soft-delete behavior.

Override the protected column methods when your schema uses different names:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Zarbinco\Nearby\Eloquent\Concerns\HasNearbyCoordinates;

class Warehouse extends Model
{
    use HasNearbyCoordinates;

    protected function nearbyLatitudeColumn(): string
    {
        return 'lat';
    }

    protected function nearbyLongitudeColumn(): string
    {
        return 'lng';
    }
}

Configure bare column names in trusted model code. Identifiers must start with a letter or underscore and contain only letters, digits, and underscores. Qualified names, SQL expressions, and user-supplied column names are unsupported. The package qualifies identifiers with the model table and quotes them using Laravel's query grammar. No service provider or facade registration is needed.

Finding Nearby Records

These examples run inside a Laravel application with the Branch model above and its existing database table.

Nearest Branches

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$branches = Branch::query()
    ->where('is_active', true)
    ->nearestTo($origin, limit: 10)
    ->get();

nearestTo() returns up to the requested number of eligible models, ordered from nearest to farthest, with the model key ascending to break equal-distance ties. It adds the computed distance to each result. The limit defaults to 10 and must be a positive PHP integer; zero, negative values, numeric strings, floats, and booleans are rejected.

There is no implicit maximum radius. The nearest record can be far away; fewer than ten eligible records produce fewer than ten results. Existing filters remain in effect. nearestTo() clears earlier ordering so distance is the primary ordering; ordering added afterward is appended to the resulting distance and key order.

Within a Radius

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);
$radius = Distance::fromKilometers(5);

$branches = Branch::query()
    ->where('is_active', true)
    ->withinRadius($origin, $radius)
    ->get();

$nearestBranches = Branch::query()
    ->where('is_active', true)
    ->withinRadius($origin, $radius)
    ->nearestTo($origin, limit: 10)
    ->get();

Radius filtering includes the boundary. Combining it with nearestTo() returns at most ten matching records inside that radius; it never expands the radius to fill the limit. withinRadius() alone does not select the computed distance or change ordering.

A zero radius includes coincident coordinates, duplicate locations, equivalent -180/+180 longitudes, and the same pole regardless of longitude. It does not apply a tolerance that turns nearby distinct locations into coincident points.

Between Distances

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$branches = Branch::query()
    ->betweenDistances(
        $origin,
        min: Distance::fromKilometers(2),
        max: Distance::fromKilometers(10),
    )
    ->orderByDistance($origin)
    ->get();

Both boundaries are inclusive. Equal minimum and maximum distances are allowed, as is a minimum of zero. A minimum greater than the maximum raises InvalidDistanceException; values are never silently swapped.

Within a Map Viewport

Radius filtering Viewport filtering
Circular radius search with matching points inside the circle Rectangular map viewport with matching points inside its bounds
withinRadius() measures distance from an origin. withinBounds() checks the rectangle's edges without calculating distance.

Bounds represents an exact latitude/longitude rectangle in decimal degrees. Its argument order is south, west, north, east:

use App\Models\Branch;
use Zarbinco\Nearby\Bounds;
use Zarbinco\Nearby\Coordinate;

$bounds = new Bounds(
    south: 35.60,
    west: 51.20,
    north: 35.80,
    east: 51.50,
);

$inside = $bounds->contains(new Coordinate(latitude: 35.6892, longitude: 51.3890));

$branches = Branch::query()
    ->where('is_active', true)
    ->withinBounds($bounds)
    ->orderBy('id')
    ->limit(100)
    ->get();

$inside is true. All four borders are inclusive. The public south, west, north, and east properties are immutable floats. Inputs must be finite PHP integers or floats: latitude must be between -90 and 90, longitude between -180 and 180, and south must not exceed north. Invalid inputs raise InvalidCoordinateException, including numeric strings supplied by a weakly typed caller.

Longitude ordering defines the interval. With west less than or equal to east, it is an ordinary interval; west greater than east crosses the antimeridian. crossesAntimeridian() reports this endpoint ordering, including the degenerate interval from +180 to -180. Latitude and longitude ranges apply independently, including at the poles.

Unlike a Coordinate, a Bounds object preserves signed longitude endpoints. Both -180 and +180 represent the same meridian when testing a point or filtering stored records:

West / east Longitude coverage
-180 / +180 All valid longitudes
+180 / -180 Only the antimeridian, including either stored sign
-180 / -170 From the antimeridian through -170, including +180
170 / +180 From 170 through the antimeridian, including -180
170 / -170 A wrapped interval across the antimeridian
30 / 30 Only longitude 30

Equal west/east is a zero-width meridian, and equal south/north is a zero-height latitude interval. Neither expands to cover the world. contains() performs exact comparisons without a tolerance and works through Composer autoloading alone.

Unwrapped Map Coordinates

Map libraries can return continuous longitudes outside the usual -180 to 180 range. Use fromViewport() for these values:

use Zarbinco\Nearby\Bounds;

$bounds = Bounds::fromViewport(south: -20, west: 175, north: 20, east: 185);

echo $bounds->west; // 175
echo $bounds->east; // -175

This factory requires east greater than or equal to west before normalization. Multiple world offsets are supported: -540 to -530 becomes -180 to -170. A span of 360 degrees or more covers every valid longitude; 10 to 370 becomes the full-world pair -180/+180. An equal pair remains a zero-width meridian.

The factory uses the same strict input and latitude validation as the constructor. It rejects unwrapped longitude magnitudes greater than 9,007,199,254,740,991 (2^53 - 1) and numerically unsafe representations instead of guessing an extent. Use the direct constructor for explicitly wrapped pairs such as west 170, east -170; that pair is not a continuous input for fromViewport().

Combining Viewports and Distances

withinBounds() adds SQL range conditions only. It excludes null and out-of-range stored coordinates, including for a full-world viewport. It does not calculate or select distance, reorder results, or add a limit. Existing selects, filters, global scopes, soft deletes, limits, offsets, and supported joins remain in effect; custom coordinate columns use the same model configuration as other proximity scopes. No migration, spatial column, or external mapping API is required.

use App\Models\Branch;
use Zarbinco\Nearby\Bounds;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$bounds = new Bounds(south: 35.60, west: 51.20, north: 35.80, east: 51.50);
$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$page = Branch::query()
    ->where('is_active', true)
    ->withinBounds($bounds)
    ->withinRadius($origin, Distance::fromKilometers(5))
    ->withDistance($origin)
    ->orderByDistance($origin)
    ->paginate(20);

$nearest = Branch::query()->withinBounds($bounds)->nearestTo($origin, limit: 10)->get();
$count = Branch::query()->withinBounds($bounds)->count();

Both viewport and radius restrictions remain active. Repeated withinBounds() calls intersect their rectangles. The scope never expands the requested viewport, and it uses the same MySQL 8.4 connection and reconnection checks as distance queries.

Grouping Results Within a Viewport

Use an explicit grouped projection to count matching records by tenant:

use App\Models\Branch;
use Zarbinco\Nearby\Bounds;

$bounds = new Bounds(south: 35.60, west: 51.20, north: 35.80, east: 51.50);

$results = Branch::query()
    ->select('tenant_id')
    ->selectRaw('COUNT(*) AS total')
    ->where('is_active', true)
    ->withinBounds($bounds)
    ->groupBy('tenant_id')
    ->havingRaw('COUNT(*) > ?', [1])
    ->orderBy('tenant_id')
    ->get();

The viewport and other WHERE conditions filter rows before GROUP BY; HAVING then filters the aggregated groups. This example returns tenants with more than one active record inside the viewport. Invalid and null coordinates are excluded, and the model's global scopes and soft-delete conditions still apply. All filtering and aggregation run in MySQL without calculating distance.

Select every grouped column and aggregate expression explicitly. Under MySQL's ONLY_FULL_GROUP_BY mode, selecting the default table.* while grouping only by tenant_id is invalid. withinBounds() preserves explicit selections, so these chaining orders are supported:

  • select()->selectRaw()->withinBounds()->groupBy()
  • select()->selectRaw()->groupBy()->withinBounds()
  • withinBounds()->select()->selectRaw()->groupBy()

In the last form, select() replaces the scope's default table.*. Calling only selectRaw() after withinBounds() appends expressions to that default selection; it does not replace it. Use select() to establish a SQL-compliant grouped projection instead of disabling strict mode. Additional WHERE and HAVING conditions, multiple grouping columns, repeated viewport filters, and cloned builders retain normal SQL semantics.

Eloquent hydrates these rows as partial reporting results containing the selected fields, such as tenant_id and total. They are not complete branch records; do not save them or rely on unselected model attributes. Use the explicit COUNT(*) AS total selection and get() when you need every group's count.

Laravel's grouped count() returns one integer, not a map of counts. Without HAVING, it returns the count from the first returned group, whose identity depends on the query's ordering. For the explicit grouped projection above, HAVING makes Laravel wrap the grouped query, so count() returns the number of qualifying groups. These are different totals; retrieve the grouped rows when you need each group's aggregate.

Grouped paginate() is supported for these explicit viewport projections. Replace get() with paginate(20) in the example to page through report rows; the paginator's total() counts groups remaining after HAVING, not their underlying records. Order by all grouping columns for stable page results. Both the pagination count and result queries retain MySQL connection and reconnection validation.

This support covers viewport reports with ordinary grouped columns and aggregate projections. Grouped calculated-distance hydration, distance ordering, and nearest-record queries are outside this contract.

Receiving a Viewport from a Frontend

HTTP inputs commonly arrive as strings, so validate them and explicitly convert them before constructing bounds. This invokable controller expects continuous unwrapped endpoints and uses application limits of 20 degrees per axis, longitude offsets within -540 to 540, and 50 records per page:

<?php

namespace App\Http\Controllers;

use App\Models\Branch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Zarbinco\Nearby\Bounds;
use Zarbinco\Nearby\Exceptions\InvalidCoordinateException;

final class BranchViewportController
{
    public function __invoke(Request $request): JsonResponse
    {
        $input = $request->validate([
            'south' => ['required', 'numeric'],
            'west' => ['required', 'numeric'],
            'north' => ['required', 'numeric'],
            'east' => ['required', 'numeric'],
        ]);

        foreach ($input as $name => $value) {
            $input[$name] = (float) $value;

            if (!is_finite($input[$name])) {
                throw ValidationException::withMessages([$name => 'Use a finite coordinate.']);
            }
        }

        try {
            $bounds = Bounds::fromViewport(
                south: $input['south'], west: $input['west'],
                north: $input['north'], east: $input['east'],
            );
        } catch (InvalidCoordinateException $exception) {
            throw ValidationException::withMessages(['viewport' => $exception->getMessage()]);
        }

        if (abs($input['west']) > 540 || abs($input['east']) > 540
            || $input['east'] - $input['west'] > 20 || $input['north'] - $input['south'] > 20) {
            throw ValidationException::withMessages(['viewport' => 'Zoom in or recenter the map.']);
        }

        return response()->json(Branch::query()
            ->select(['id', 'name', 'latitude', 'longitude'])
            ->where('is_active', true)
            ->withinBounds($bounds)
            ->orderBy('id')
            ->simplePaginate(50)
            ->withQueryString());
    }
}

The simple paginator avoids a total-count query for each map update. withQueryString() keeps the viewport query parameters in pagination links, so following the next-page URL searches the same viewport. Apply your application's authentication and rate-limiting policies to the route. Debounce map movement requests and choose viewport and page-size limits appropriate to your workload. These limits belong to the example endpoint; Bounds and withinBounds() have no hidden result limit. Pagination limits transferred records, while a broad or dense viewport may still examine many rows. Coordinate indexes and ordinary tenant/application filters can help; inspect the actual query plan before exposing a high-volume endpoint.

Ordering and Reading Distances

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Unit;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$branches = Branch::query()
    ->withDistance($origin)
    ->orderByDistance($origin, direction: 'desc')
    ->get();

foreach ($branches as $branch) {
    $meters = $branch->nearby_distance_meters;
    $distance = $branch->nearbyDistance();

    if ($distance !== null) {
        printf(
            "%s: %.2f km (%.2f mi)\n",
            $branch->name,
            $distance->in(Unit::Kilometer),
            $distance->in(Unit::Mile),
        );
    }
}

orderByDistance() accepts asc or desc, case-insensitively, and excludes records with missing or invalid coordinates. It appends distance ordering and an ascending model-key tie-breaker to existing orderBy() clauses. Use reorder()->orderByDistance(...) when distance should replace earlier ordering. This scope does not select the computed distance; add withDistance() when you need it.

withDistance() preserves the original models and selected attributes. Called alone, it retains records with null or out-of-range coordinates and returns a null distance for them. Valid distances are unrounded PHP floats in meters, compatible with Distance::fromMeters(). nearbyDistance() returns the corresponding immutable Distance object, or null when no computed result is available. All six distance units remain available.

The read-only nearby_distance_meters property is a virtual attribute. Models retrieved through withDistance() or nearestTo() include it in toArray() and JSON, including a null value when selected coordinates are invalid. Ordinary models do not automatically gain this serialized field; direct property access and nearbyDistance() still return null. Explicit $appends, $hidden, and $visible settings remain respected.

The trait keeps the value outside Eloquent's persisted attributes, so updating and saving the model, including saveQuietly(), does not write it to the database. Assigning it raises LogicException. The value describes the query result at the time it was retrieved; changing coordinates, saving, or calling refresh() does not recalculate it. Run a new withDistance() query when you need an updated result.

Reserve nearby_distance_meters for this property. Do not define a real column, default attribute, cast, accessor, or selected SQL alias with that name. Conflicting selections already present when withDistance() is called raise InvalidArgumentException; conflicting model attributes or casts, and conflicting selected attributes or real columns encountered during result hydration, raise LogicException. The package does not inspect database schemas to discover conflicts before querying.

Choose explicit columns before adding the distance:

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$branches = Branch::query()
    ->select(['branches.id', 'branches.name'])
    ->withDistance($origin)
    ->get();

Calling select() afterward replaces the computed selection and raises LogicException when affected models are hydrated. Use addSelect() to append columns. Repeated withDistance() calls expose the result from the last supplied origin; filters and ordering retain the origins passed to their own scopes.

Pagination and Aggregates

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$query = Branch::query()
    ->where('is_active', true)
    ->withinRadius($origin, Distance::fromKilometers(5));

$count = (clone $query)->count();
$hasBranches = (clone $query)->exists();
$closest = (clone $query)->nearestTo($origin, limit: 1)->first();

$page = (clone $query)
    ->withDistance($origin)
    ->orderByDistance($origin)
    ->paginate(20);

Use radius or distance-band filtering with ordinary paginate(); choose its page size through paginate(). nearestTo() is intended for a limited result set retrieved with get() or first().

The following retrieval methods also preserve geographic filtering and ordering. Computed values are available when the query includes withDistance() or nearestTo():

Retrieval method Result and distance behavior
get(), first(), firstOrFail() Normal Eloquent models or collections with hydrated distances; normal missing-model behavior applies.
paginate(), simplePaginate() Normal Laravel paginators with computed distances on their models.
cursor(), lazy() Laravel lazy collections of models with hydrated distances.
chunk() Eloquent model batches passed to your callback; an existing nearest-result limit is respected.
pluck() A scalar collection of existing database columns. Use get()->pluck('nearby_distance_meters') for calculated values.
value() The first scalar value, or null for no result; calculated distance works when selected by a proximity scope.

cursor() follows Laravel's usual limitation on eager loading. Use get() or lazy() when relationships need eager loading. Basic belongsTo eager loading and relationship filters compose with the geographic scopes; retain the primary and foreign keys Laravel needs when choosing explicit columns. ID-based chunking with distance ordering has not been verified.

Query Behavior and Limits

Method Filters invalid or missing coordinates Adds computed distance Ordering
withDistance($origin) No; distance is null Yes Unchanged
orderByDistance($origin, $direction) Yes No Appends distance and model key
withinRadius($origin, $radius) Yes No Unchanged
withinBounds($bounds) Yes No Unchanged
betweenDistances($origin, $min, $max) Yes No Unchanged
nearestTo($origin, $limit) Yes Yes Replaces earlier ordering with distance and model key

All methods return a normal Eloquent builder and keep ordinary conditions, global scopes, and soft-delete filters. Radius, distance-band, and viewport conditions execute in MySQL and work with count(), exists(), first(), get(), and normal pagination. SQL uses parameter bindings for coordinates, bounds, and distance thresholds; no values are rounded before filtering.

Basic joins are supported: coordinate columns are qualified with the model table, and the default selection is the model's table.*, so joined-table attributes are not selected automatically. Keep the model table as the query's FROM source. Grouped viewport reports require the explicit projections described in Grouping Results Within a Viewport. Other grouped query shapes, model table aliases, subquery FROM sources, schema-qualified table names, unions, distinct projections, other custom raw result shapes, and cursor pagination are outside the supported query contract. A join that produces duplicate model rows retains normal SQL behavior.

Application global scopes follow Laravel's normal withoutGlobalScopes() behavior. Connection validation remains attached to geographic queries and their clones.

The proximity scopes support retrieval and aggregate queries; bulk geographic update() and delete() operations are outside the supported query contract. Saving an individual retrieved model remains supported.

MySQL 8.4.x is the supported query engine. PostgreSQL, SQLite, MariaDB, and other MySQL versions raise UnsupportedDatabaseException; unsupported drivers are rejected before executing the geographic SQL, and server versions are checked when the query is compiled or executed:

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Exceptions\UnsupportedDatabaseException;

try {
    $branches = Branch::query()
        ->nearestTo(new Coordinate(latitude: 35.6892, longitude: 51.3890))
        ->get();
} catch (UnsupportedDatabaseException $exception) {
    report($exception);
}

Every read, write, and failover endpoint must use MySQL 8.4. Retained builders validate replacement PDOs after explicit reconnection and during Laravel's automatic retry of a lost connection. Unsupported replacements raise UnsupportedDatabaseException before the geographic statement is prepared. Validation uses PDO's server-version metadata, adds no separate SQL query, and is cached per physical PDO within a query and its clones. Laravel's read/write routing and transaction retry rules remain in effect.

Execute proximity queries through a model using HasNearbyCoordinates, Laravel's standard MySQL connection, and its standard base query builder. Custom connection subclasses and custom base query builders are rejected for geographic queries because their execution behavior cannot be validated safely. Basic relationship filters on the geographic model and eager loading remain supported. Embedding geographic scopes in subqueries executed by a different model, or manually executing SQL exported with toSql(), is outside the supported query contract.

Performance

When you search within a radius, Laravel Nearby adds latitude and longitude ranges covering the complete spherical search area. These ranges narrow the candidates before the exact Haversine condition. A rectangle also contains points outside the circle, so the exact distance comparison always remains in the query. betweenDistances() uses its maximum distance for the same preliminary filter and retains both exact boundaries.

The ranges handle the antimeridian and poles and expand outward to account for floating-point rounding. The final distance threshold is never expanded. Zero-radius and global searches skip the box, as do numerically uncertain cases where a restrictive box would be unsafe. No candidate limit or implicit radius is added.

An explicit withinBounds() viewport uses exact inclusive coordinate ranges. It adds no Haversine work or outward expansion. Ordinary coordinate indexes, optionally preceded by frequently equality-filtered application columns, can help these predicates. The optimizer chooses its access path; index use is not guaranteed, especially when most rows match.

Use ordinary application conditions to reduce eligible records and combine a radius with nearest ordering when your use case has a meaningful distance limit:

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);

$branches = Branch::query()
    ->where('is_active', true)
    ->withinRadius($origin, Distance::fromKilometers(5))
    ->nearestTo($origin, limit: 10)
    ->get();

Filtering, sorting, and limiting happen in SQL. nearestTo() without a radius can still calculate and sort distances for every eligible row: limit(10) limits returned rows, not rows examined. A large radius or a dense local cluster can also leave many candidates. Select only the model attributes your application needs when transferring large payloads.

Optional Indexes

Ordinary B-tree indexes can help MySQL use the preliminary coordinate ranges. Depending on your queries, useful candidates include separate latitude or longitude indexes, (latitude, longitude) or (longitude, latitude), or an equality-filtered application column followed by coordinates, such as (is_active, latitude, longitude).

These are optional application decisions, not installation requirements. The package never creates indexes or modifies your schema. For example, an application that frequently filters active branches might evaluate this index in its own development environment:

CREATE INDEX branches_active_lat_lon
    ON branches (is_active, latitude, longitude);

Index order matters. After an index range condition, another indexed column may help filter entries without further narrowing the scanned range. MySQL can prefer a table scan when much of the table matches. Compare plans and timings on representative data before choosing indexes; no layout guarantees the best plan, and ordinary numeric columns are not spatial indexes.

Inspecting Queries

Use MySQL's EXPLAIN with the query's original bindings to inspect the chosen access path:

use App\Models\Branch;
use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;

$origin = new Coordinate(latitude: 35.6892, longitude: 51.3890);
$query = Branch::query()
    ->where('is_active', true)
    ->withinRadius($origin, Distance::fromKilometers(5))
    ->nearestTo($origin, limit: 10);

$base = $query->toBase();
$plan = $base->getConnection()->select(
    'EXPLAIN FORMAT=JSON '.$base->toSql(),
    $base->getBindings(),
);

Inspect the selected key, estimated rows, and any sorting or temporary-table work. EXPLAIN ANALYZE executes the query and reports actual iterator activity; use it with care in a development environment. Plans and timings depend on data distribution, selectivity, existing indexes, query shape, and server load. Not every bounded search will be faster.

Optional Benchmarks

The benchmark guide explains how to compare exact-distance queries with bounded searches on deterministic uniform and clustered datasets, using several ordinary index layouts. From a dedicated MySQL 8.4 development database configured through DB_* environment variables:

php benchmarks/benchmark.php --run --sizes=1000 --warmups=2 --repetitions=5

Larger datasets are opt-in. Keep benchmarks separate from production databases and routine CI; performance timings are measurements, not correctness assertions.

Working with Coordinates

Always supply latitude first, longitude second, in decimal degrees:

  • Latitude is between -90 and 90, inclusive. Positive values are north of the equator.
  • Longitude is between -180 and 180, inclusive. Positive values are east of Greenwich.
  • Both parameters accept PHP integers and floats, including zero. Values are stored as floats.
  • Longitude -180 is stored as +180: these represent the same antimeridian.

Read coordinates through their public, immutable properties:

use Zarbinco\Nearby\Coordinate;

$location = new Coordinate(latitude: 0, longitude: -180);

echo $location->latitude;  // 0
echo $location->longitude; // 180

Numeric strings, booleans, null, arrays, objects, non-finite numbers, and out-of-range coordinates raise InvalidCoordinateException. These rules also apply when your calling file does not enable strict typing. Validate and parse HTTP request text in your application before creating a coordinate; the package does not parse request values automatically.

Calculating Distance

Geo::distance(Coordinate $origin, Coordinate $destination): Distance returns the shortest surface distance on the package's spherical Earth model:

use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Geo;
use Zarbinco\Nearby\Unit;

$distance = Geo::distance(
    new Coordinate(latitude: 0, longitude: 0),
    new Coordinate(latitude: 0, longitude: 1),
);

echo $distance->in(Unit::Meter);

One degree along the equator is approximately 111,195.080 meters on this model. Identical locations return zero, including either pole with different longitudes. Distances across the antimeridian use the shorter arc.

For direct calculator usage, HaversineCalculator uses the same implementation as Geo:

use Zarbinco\Nearby\Calculators\HaversineCalculator;
use Zarbinco\Nearby\Coordinate;

$calculator = new HaversineCalculator();
$distance = $calculator->calculate(
    new Coordinate(latitude: 0, longitude: 0),
    new Coordinate(latitude: 0, longitude: 1),
);

Supported Units

Conversions use these defined factors, with meters as the internal base unit:

Enum case Abbreviation Meters per unit
Unit::Meter m 1
Unit::Kilometer km 1000
Unit::Mile mi 1609.344
Unit::Foot ft 0.3048
Unit::Yard yd 0.9144
Unit::NauticalMile nmi 1852

Miles, feet, and yards use international definitions. Unit is a string-backed enum; Unit::Kilometer->value is km, and Unit::Kilometer->metersPerUnit() returns 1000.0.

Converting Distances

Create a distance with Distance::from($value, $unit) or a convenient factory, then use in($unit) to get a float in the chosen unit:

use Zarbinco\Nearby\Distance;
use Zarbinco\Nearby\Unit;

$distance = Distance::fromKilometers(5);
$meters = $distance->in(Unit::Meter); // 5000.0
$miles = $distance->in(Unit::Mile);

printf("%.6f mi\n", $miles); // 3.106856 mi

$nauticalDistance = Distance::from(2, Unit::NauticalMile);
$meters = $nauticalDistance->in(Unit::Meter); // 3704.0

All factories accept integers and floats:

use Zarbinco\Nearby\Distance;

$meters = Distance::fromMeters(500);
$kilometers = Distance::fromKilometers(5);
$miles = Distance::fromMiles(3);
$feet = Distance::fromFeet(100);
$yards = Distance::fromYards(50);
$nauticalMiles = Distance::fromNauticalMiles(2);

Negative distances, invalid input types, NaN, and infinity raise InvalidDistanceException. Zero is valid. Conversion also raises this exception if a finite value overflows when stored in meters or returned in another unit. Neither construction nor conversion rounds the result.

Working with Results

Distance is immutable. Convert or round for display without changing the stored value:

use Zarbinco\Nearby\Distance;
use Zarbinco\Nearby\Unit;

$distance = Distance::fromMeters(1234.56789);
$label = number_format($distance->in(Unit::Kilometer), 2).' km';

echo $label; // 1.23 km
$originalMeters = $distance->in(Unit::Meter); // 1234.56789

Handling Invalid Input

Catch the exception matching the value you are constructing. Both exceptions extend PHP's InvalidArgumentException:

use Zarbinco\Nearby\Coordinate;
use Zarbinco\Nearby\Distance;
use Zarbinco\Nearby\Exceptions\InvalidCoordinateException;
use Zarbinco\Nearby\Exceptions\InvalidDistanceException;

try {
    $coordinate = new Coordinate(latitude: 91, longitude: 0);
} catch (InvalidCoordinateException $exception) {
    echo $exception->getMessage();
}

try {
    $distance = Distance::fromMeters(-1);
} catch (InvalidDistanceException $exception) {
    echo $exception->getMessage();
}

Calculation Accuracy

The Haversine formula calculates a great-circle surface distance using a fixed spherical mean Earth radius of 6,371,008.8 meters. Results are geographic distances, not driving or walking distances, road-network distances, or travel times. The spherical model does not account for terrain, altitude, or Earth's ellipsoidal shape and is not survey-grade ellipsoidal measurement.

Calculations use PHP floating-point arithmetic. Defined conversion factors are used without intentional rounding, but decimal numbers and conversions remain subject to normal binary floating-point limits. Very small values can underflow to zero; nearly antipodal points lose some precision and can round to the half-circumference. Results remain finite and bounded by that half-circumference. Round only when presenting results, according to your application's needs.

MySQL queries use the same radius and Haversine model as the PHP calculator, with clamped intermediate values and no SQL rounding. The two math runtimes can differ slightly. The integration suite uses micrometer-scale absolute tolerances for ordinary examples, smaller tolerances for short arcs, and up to 0.5 meters for difficult oblique antipodal examples. These tolerances describe numerical comparisons, not the accuracy of a spherical model against Earth's real shape.

Stored coordinate precision also matters: a DECIMAL column rounds values to its configured scale before calculation. Radius and distance-band boundaries compare MySQL's unrounded result directly with the supplied distance in meters. There is no boundary expansion to compensate for differences between PHP and MySQL.

Running Tests

From the package directory:

composer install
composer validate --strict
composer test
composer test:compatibility
composer examples
php examples/bounds.php

composer test runs the standalone suite without connecting to a database. It covers validation (including weakly typed callers), immutability, unit conversions and overflow, and geographic edge cases against analytic references. composer test:compatibility exercises Composer constraints and Laravel's Illuminate replacement behavior using isolated metadata fixtures.

For Eloquent integration tests, prepare a dedicated, disposable MySQL 8.4 database and configure its connection. The tests create, drop, or recreate nearby_test_branches, nearby_test_custom_branches, and nearby_test_regions; never point them at an application or production database.

In Bash, set the connection values for that test database and enter its password when prompted:

export DB_HOST=127.0.0.1
export DB_PORT=3306
export DB_DATABASE=nearby_test
export DB_USERNAME=nearby_test
read -s -p 'Test database password: ' DB_PASSWORD
export DB_PASSWORD
composer test:integration

Use your shell's environment-variable syntax on other platforms, and keep credentials outside tracked files. composer test:all runs both suites with the same database environment. The integration suite executes real SQL through Laravel and Orchestra Testbench; it checks filters, ordering, precision, query composition, model behavior, and input safety. The CI configuration includes a separate MySQL 8.4 service for these tests.

To check actual dependency resolution separately from the quick metadata fixtures:

composer test:compatibility:real

The consumer testing guide describes installing this checkout into a disposable Laravel application, booting Artisan and HTTP requests, and exercising its existing coordinate columns. Those checks use a separate database ending in _consumer and do not install Testbench. The guide also explains security-blocked minimum-version results and why consumer dependencies must be resolved for the PHP runtime being tested.

The executable files in examples/ demonstrate distance calculation, conversions, direct construction, viewport containment, and exception handling using Composer autoloading alone.

Local Development Installation

To develop against a checkout, place this repository in a directory named nearby alongside your application and merge this path repository into the application's composer.json:

{
    "repositories": [
        {
            "type": "path",
            "url": "../nearby",
            "options": {
                "versions": {
                    "zarbinco/laravel-nearby": "dev-main"
                }
            }
        }
    ]
}

Then run composer require "zarbinco/laravel-nearby:dev-main@dev" in the application. Adjust url for another checkout location. In the package checkout itself, run composer install and composer examples to execute the standalone examples.

Contributing

See CONTRIBUTING.md for the development workflow and documentation guidance.

Security

See SECURITY.md for reporting suspected vulnerabilities without disclosing sensitive details publicly.

License

Laravel Nearby is available under the MIT license.