jackardios / laravel-eloquent-spatial
Spatial library for Laravel
Package info
github.com/Jackardios/laravel-eloquent-spatial
pkg:composer/jackardios/laravel-eloquent-spatial
Requires
- php: ^8.3
- ext-json: *
- ext-pdo: *
- brick/geo: ^0.13.1
- laravel/framework: ^12.18|^13.0
Requires (Dev)
- larastan/larastan: ^3.1
- laravel/pint: ^1.14
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.1|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel package for working with spatial data types and functions in Eloquent.
This package is a fork of matanyadaev/laravel-eloquent-spatial, maintained by Jackardios.
Supported Databases
- MySQL 5.7 / 8.0 / 8.4
- MariaDB 10.11 / 11.4
- PostgreSQL 12–17 with PostGIS 3.4+
These are the versions that the tests run on.
Requirements
- PHP 8.3+
- Laravel 12.18+ / 13.x
For PHP 8.1 or 8.2, or Laravel 10, 11 or 12 before 12.18, use version 4.x.
Installation
composer require jackardios/laravel-eloquent-spatial
Quick Start
1. Create a Migration
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('places', function (Blueprint $table) { $table->id(); $table->string('name'); $table->geometry('location', subtype: 'point')->nullable(); $table->geometry('area', subtype: 'polygon')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('places'); } };
To restrict a column to one SRID, pass it to the column, for example $table->geometry('location', subtype: 'point', srid: 4326). MySQL 8 then rejects geometries with a different SRID.
2. Set Up Your Model
namespace App\Models; use Illuminate\Database\Eloquent\Model; use Jackardios\EloquentSpatial\Objects\Point; use Jackardios\EloquentSpatial\Objects\Polygon; use Jackardios\EloquentSpatial\Traits\HasSpatial; /** * @property Point $location * @property Polygon $area */ class Place extends Model { use HasSpatial; protected $fillable = [ 'name', 'location', 'area', ]; protected $casts = [ 'location' => Point::class, 'area' => Polygon::class, ]; }
3. Work with Spatial Data
use App\Models\Place; use Jackardios\EloquentSpatial\Objects\Point; use Jackardios\EloquentSpatial\Objects\Polygon; use Jackardios\EloquentSpatial\Objects\LineString; use Jackardios\EloquentSpatial\Enums\Srid; // Create a place with a point location // Note: Point constructor uses (longitude, latitude) order $eiffelTower = Place::create([ 'name' => 'Eiffel Tower', 'location' => new Point(2.2945, 48.8584, Srid::WGS84), ]); // Create with polygon area $centralPark = Place::create([ 'name' => 'Central Park', 'area' => new Polygon([ new LineString([ new Point(-73.9819, 40.7681), new Point(-73.9580, 40.8006), new Point(-73.9498, 40.7969), new Point(-73.9737, 40.7644), new Point(-73.9819, 40.7681), // Close the ring ]), ], Srid::WGS84), ]); // Access coordinates echo $eiffelTower->location->longitude; // 2.2945 echo $eiffelTower->location->latitude; // 48.8584 echo $eiffelTower->location->srid; // 4326 // Convert to different formats $eiffelTower->location->toWkt(); // POINT(2.2945 48.8584) $eiffelTower->location->toJson(); // {"type":"Point","coordinates":[2.2945,48.8584]} $eiffelTower->location->toArray(); // ['type' => 'Point', 'coordinates' => [2.2945, 48.8584]]
The geometries that a query compares must have the same SRID, so the examples use SRID 4326 throughout. Without an SRID, a geometry has the default SRID, 0 unless you change it.
Geometry Classes
All geometry classes support creating instances from various formats:
use Jackardios\EloquentSpatial\Objects\Point; // From constructor $point = new Point(longitude: 2.2945, latitude: 48.8584, srid: 4326); // From WKT, or from EWKT with its SRID $point = Point::fromWkt('POINT(2.2945 48.8584)', srid: 4326); $point = Point::fromWkt('SRID=4326;POINT(2.2945 48.8584)'); // From a GeoJSON geometry, Feature or FeatureCollection $point = Point::fromJson('{"type":"Point","coordinates":[2.2945,48.8584]}'); // From array $point = Point::fromArray(['type' => 'Point', 'coordinates' => [2.2945, 48.8584]]); // From WKB as MySQL stores it, from WKB or EWKB, or from hex EWKB as PostGIS returns it $point = Point::fromWkb($binaryData);
Each method reads only its own format: fromWkt() does not accept GeoJSON, and fromJson() does not accept WKT. Z and M coordinates are read and dropped. Factory::parse() detects which of these formats a string is in. It does not apply the default SRID: the SRID is the one in EWKT, EWKB or the MySQL format, and 0 for WKT and GeoJSON, while fromWkt(), fromJson() and fromArray() use the default SRID.
Available Geometry Types
| Class | Description |
|---|---|
Point |
Single coordinate (longitude, latitude) |
LineString |
Ordered sequence of Points |
Polygon |
Closed shape defined by LineStrings |
MultiPoint |
Collection of Points |
MultiLineString |
Collection of LineStrings |
MultiPolygon |
Collection of Polygons |
GeometryCollection |
Mixed collection of any geometry types |
BoundingBox |
Rectangular bounds with antimeridian support |
Coordinate Validation
The Point class validates coordinates automatically. With SRID 0 (the default) or 4326, the coordinates are degrees:
// Valid coordinates $point = new Point(180, 90); // OK $point = new Point(-180, -90); // OK // Invalid coordinates throw InvalidArgumentException $point = new Point(200, 0); // Error: Longitude must be between -180 and 180 $point = new Point(0, 100); // Error: Latitude must be between -90 and 90
Other SRIDs have their own units, so their ranges are not checked. With any SRID, the coordinates must be finite:
use Jackardios\EloquentSpatial\Enums\Srid; $point = new Point(-8238310.24, 4970071.58, Srid::WEB_MERCATOR); // OK, metres $point = new Point(NAN, 0); // Error: Coordinates must be finite numbers
Spatial Query Scopes
The HasSpatial trait provides query scopes for spatial operations:
Distance Queries
use App\Models\Place; use Jackardios\EloquentSpatial\Objects\Point; $referencePoint = new Point(-0.1246, 51.5007, 4326); // Add distance to results $places = Place::query() ->withDistanceSphere('location', $referencePoint) ->get(); foreach ($places as $place) { echo $place->distance; // Distance in meters } // Filter by distance $nearbyPlaces = Place::query() ->whereDistanceSphere('location', $referencePoint, '<', 5000) // Within 5km ->get(); // Order by distance $closestPlaces = Place::query() ->orderByDistanceSphere('location', $referencePoint) ->limit(10) ->get();
The operator must be one of =, <, >, <=, >=, <> and !=, and the direction asc or desc in any case. Anything else throws InvalidArgumentException, because these arguments are written into the SQL. The alias of withDistance() and withDistanceSphere() is quoted as a column name.
Spatial Relationship Queries
use Jackardios\EloquentSpatial\Enums\Srid; use Jackardios\EloquentSpatial\Objects\Point; use Jackardios\EloquentSpatial\Objects\Polygon; $searchArea = Polygon::fromJson('{"type":"Polygon","coordinates":[[[2.2,48.8],[2.4,48.8],[2.4,48.9],[2.2,48.9],[2.2,48.8]]]}', Srid::WGS84); $point = new Point(2.2945, 48.8584, Srid::WGS84); // Find places within an area Place::whereWithin('location', $searchArea)->get(); Place::whereNotWithin('location', $searchArea)->get(); // Find places whose area contains a point Place::whereContains('area', $point)->get(); Place::whereNotContains('area', $point)->get(); // Other spatial relationships Place::whereTouches('area', $searchArea)->get(); Place::whereIntersects('location', $searchArea)->get(); Place::whereDisjoint('location', $searchArea)->get(); Place::whereOverlaps('area', $searchArea)->get(); Place::whereEquals('location', $point)->get(); // Filter by SRID Place::whereSrid('location', '=', 4326)->get(); // Get centroid; MySQL 8 does not compute the centroid of a polygon in SRID 4326 Place::query() ->withCentroid('area') ->withCasts(['centroid' => Point::class]) ->get();
BoundingBox
The BoundingBox class represents rectangular geographic bounds:
use Jackardios\EloquentSpatial\Enums\Srid; use Jackardios\EloquentSpatial\Objects\BoundingBox; use Jackardios\EloquentSpatial\Objects\Point; // Create from corner points $bbox = new BoundingBox( leftBottom: new Point(-74.0, 40.7), rightTop: new Point(-73.9, 40.8) ); // Create from geometry $bbox = BoundingBox::fromGeometry($polygon); // Create from points with padding; a single point gives a box of zero size without padding $bbox = BoundingBox::fromPoints($pointsArray, minPadding: 0.01); // Access bounds (copies of the corners) $bbox->getLeftBottom(); // Bottom-left Point $bbox->getRightTop(); // Top-right Point // Convert to geometry, optionally with an SRID $polygon = $bbox->toPolygon(); $geometry = $bbox->toGeometry(Srid::WGS84); // Returns MultiPolygon if crosses antimeridian // Check if crosses antimeridian (dateline) $bbox->crossesAntimeridian(); // true/false // Serialize $bbox->toArray(); // ['left' => ..., 'bottom' => ..., 'right' => ..., 'top' => ...] $bbox->toJson();
BoundingBox as Model Attribute
use Jackardios\EloquentSpatial\Objects\BoundingBox; class Region extends Model { use HasSpatial; protected $casts = [ // Store as geometry column 'bounds' => BoundingBox::class, // Or as a geometry column with SRID 4326 'bounds' => BoundingBox::class . ':geometry,4326', // Or store as JSON 'bounds' => BoundingBox::class . ':json', ]; }
The geometry is stored with the default SRID unless the cast names one. On MySQL 8, a column with an SRID accepts only geometries with that SRID.
SRID Support
Spatial Reference Identifiers define coordinate systems:
use Jackardios\EloquentSpatial\Enums\Srid; use Jackardios\EloquentSpatial\EloquentSpatial; // Available SRID constants Srid::WGS84; // 4326 - GPS coordinates Srid::WEB_MERCATOR; // 3857 - Web maps (Google Maps, etc.) // Set default SRID for all geometries EloquentSpatial::setDefaultSrid(Srid::WGS84);
Extending Geometry Classes
Using Macros
use Jackardios\EloquentSpatial\Objects\Geometry; use Jackardios\EloquentSpatial\Objects\Point; // Register in a service provider Geometry::macro('distanceToKm', function (Point $other): float { /** @var Geometry $this */ // Custom distance calculation }); // Usage $point->distanceToKm($otherPoint);
Custom Geometry Classes
use Jackardios\EloquentSpatial\Objects\Point; use Jackardios\EloquentSpatial\EloquentSpatial; class CustomPoint extends Point { public function toLatLngArray(): array { return ['lat' => $this->latitude, 'lng' => $this->longitude]; } } // Register in service provider EloquentSpatial::usePoint(CustomPoint::class);
Parsed and database values are then created as CustomPoint. A Point::class cast accepts it, and GeoJSON and WKB write it as a Point.
Long-running processes (Octane, queue workers)
The classes registered with EloquentSpatial::use*(), the default SRID and macros are static, so they are shared by all requests that a process handles. Set them once in the boot() method of a service provider, never per request.
Dirty Checks
The geometry and bounding box casts compare values themselves, so models detect changes correctly without the HasSpatial trait. A change of only the SRID or only the geometry type, such as a Point replaced by a MultiPoint with the same coordinates, is saved.
Limitations
- MariaDB does not support nested geometry collections:
ST_GeomFromText()returnsNULLfor them, so such a value is saved asNULL. - MySQL 8 reads WKT with a geographic SRID such as 4326 as latitude first. The package adds
'axis-order=long-lat'to the SQL it generates. If you assign a raw expression yourself, add it too:
$place->location = DB::raw("ST_GeomFromText('POINT(2.2945 48.8584)', 4326, 'axis-order=long-lat')");
- Geometries nested more than 64 levels deep are not read. Such values throw
InvalidArgumentException, because reading them could crash PHP. - Empty geometries other than
GEOMETRYCOLLECTION EMPTY, such asPOLYGON EMPTY,MULTIPOLYGON EMPTY,LINESTRING EMPTYandPOINT EMPTY, are not read and throwInvalidArgumentException. PostGIS returns them from functions such asST_Intersection()andST_Difference(). A model whose geometry column holds such a value throws when the attribute is read, so storeNULLinstead. - The rings of a polygon are not checked to be closed.
POLYGON((0 0, 1 0, 1 1))is read from WKT and GeoJSON and can be created from points; the database rejects it when it is saved.
API Reference
For complete API documentation, see API.md.
Upgrading
See UPGRADE.md for upgrade instructions from previous versions.
Development
# Start database containers docker-compose up -d # Run tests composer pest:mysql composer pest:mariadb composer pest:postgres # Static analysis composer phpstan # Code formatting composer pint
License
MIT License. See LICENSE.md for details.