clickbar/laravel-magellan
Laravel Magellan is a modern PostGIS toolbox for Laravel. It provides geometry data classes, generators and parsers, request validation, and seamless database interaction for inserting, updating, and querying spatial data with PostGIS functions.
Requirements: PostgreSQL with the PostGIS extension (MySQL/SQLite are out), a recent Laravel + PHP (v2 targets modern Laravel/PHP — check the badge/composer.json before upgrading a legacy app).
Install in four steps:
composer require clickbar/laravel-magellan
# publishes config/magellan.php
php artisan vendor:publish --tag="magellan-config"
# publishes a migration that runs "CREATE EXTENSION postgis"
php artisan vendor:publish --tag="magellan-migrations"
php artisan migrate
Where to look first in the README: What's included (shows the four pillars: geometry data classes, generators/parsers, request validation, DB interaction), then Creating Tables with PostGIS Columns → Preparing the Model → Using the geometry data classes. Skip Generators & Parsers on day one — you only need it when talking to a frontend map or an import/export job.
First use case — store a point and read it back:
// migration
Schema::create('places', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->geometry('location', subtype: 'point', srid: 4326);
$table->spatialIndex('location'); // don't skip this
$table->timestamps();
});
use Clickbar\Magellan\Data\Geometries\Point;
class Place extends Model
{
protected $casts = [
'location' => Point::class, // hydration + persistence handled for you
];
}
$place = Place::create([
'name' => 'Café Kiez',
'location' => Point::makeGeodetic(52.5200, 13.4050), // lat, lng — geodetic
]);
That cast is the whole value proposition: $place->location is a Point object, $place->save() writes valid EWKT/WKB, and queries via a raw ST_AsText are no longer needed for the happy path.
1. Cast every geometry column, and only geometry columns. Keep subtypes aligned with the DB (subtype: 'point' ↔ Point::class). A mismatch only surfaces at write time as a PostGIS type error. For nullable columns, the cast naturally yields null.
2. Validate GeoJSON at the edge, store geometry at the core. Mobile/web map widgets emit GeoJSON. Use the shipped request rules for shape validation (geometry type, dimension, coordinate sanity — see the Request Validation and Transformation section for the exact rule classes in your version) and let the transformation step hand you a geometry object, so the controller never touches strings:
public function store(StorePlaceRequest $request)
{
return Place::create($request->validated()); // 'location' already a Point
}
If you'd rather parse manually, GeoJsonParser / WKTParser / WKBParser are the entry points, and the *Generator counterparts turn a geometry back into a string. Parsing is also the right tool for imports (GPX/KML jobs, CSV dumps) where you have no Eloquent model in play.
3. Serialize back to GeoJSON in API Resources rather than exposing EWKT. Generate GeoJSON once in toArray() on the resource so every endpoint returns the same shape:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'location' => GeoJsonGenerator::generate($this->location), // or ->toGeoJson()
];
}
4. Nearby / containment searches. The package's Interaction with the database section documents the query-builder helpers; whether or not the helper exists for your predicate, the pattern is the same — build the search geometry once, bind it, and use an index-friendly predicate:
$searchPoint = Point::makeGeodetic($lat, $lng);
Place::query()
->whereRaw('ST_DWithin(location::geography, ?::geography, ?)', [
Magellan::toSql($searchPoint), // serialise the value object for a raw binding
$radiusInMeters,
])
->orderByRaw('location <-> ?::geometry', [Magellan::toSql($searchPoint)]) // KNN
->limit(20)
->get();
Rule of thumb: ST_DWithin for filtering (uses the GiST index), ST_Distance only for ordering/display. Cast to geography when you want metres; on plain geometry in SRID 4326 a distance is in degrees and your "5 km" radius is silently wrong.
5. Keep SRID in config, not in your head. Put the project's SRID (and whether you're geodetic or projected) in config/magellan.php and reference it from factories, parsers and validation rules. Every parser/generator accepts an SRID override — use it explicitly when ingesting third-party data (e.g. Swiss LV95 / EPSG:2056) and let PostGIS reproject with ST_Transform.
6. Factories and seeders. Generate plausible fixtures with Point::makeGeodetic(...) inside a bounding box; keep a handful of fixed coordinates as named constants so assertions and snapshot tests are stable.
7. Testing. Point your phpunit.xml at a Postgres database with PostGIS enabled, and run RefreshDatabase. The published postgis migration must run before your own migrations — if you squash migrations or use migrate:fresh --seed in CI, verify the extension is created first.
Point::make($x, $y) are X-then-Y; makeGeodetic($lat, $lng) is latitude-first. Mixing them puts Berlin in the Indian Ocean and you only notice on a map. Pick one convention per codebase and never mix helpers on the same column.ST_Transform one side. Never reproject inside a WHERE on an indexed column — the index is bypassed.ST_Distance on a 4326 geometry returns degrees. If you need metres, either declare the column as geography (subtype-aware migration helper) or cast ::geography at query time. Decide once; retrofitting is a migration + index rebuild.Place::insert([...]) and upsert() never invoke the geometry cast, so you'd be writing raw values. Either loop with create(), or construct the SQL literal yourself (Magellan::toSql($point) or the WKT/WKB generator) for CSV imports. Use DB::transaction() around the loop.POINT Z, empty geometries, and 3D data from GPS tracks will parse, but validation rules with a fixed dimension will reject them. Check the coordinate dimension on your incoming payload before the rule fires, and store an empty collection rather than null for "no shape yet" only if the column is NOT NULL.toSql() / toArray() truncation. Float precision survives the round-trip to PostGIS as doubles, but JSON encoders apply serialize_precision. If map pins look off, dump the raw ST_AsText(location) in tinker and compare.CREATE EXTENSION postgis may need a privileged role or a parameter-group change. Also confirm the extension lands in the schema listed in your connection's search_path, otherwise PostGIS functions aren't found.ST_DWithin, && bounding-box operators and KNN <->; a WHERE ST_Contains(a, b) with a computed geometry on the left side may still sequence-scan. Combine a bounding-box prefilter (ST_MakeEnvelope or &&) with the exact predicate for large tables.tinker, Place::first()->location shows the value object; Place::selectRaw('ST_AsText(location) as wkt')->first()->wkt shows what the DB actually holds; Place::where(...)->toSql() plus getBindings() shows what you're sending. Most Magellan issues are found in those three lines.getLat()/getLng() wrappers, a toLeaflet() array, a distance formatter) as a model accessor or an extension of the geometry class, rather than sprinkling ST_* calls through controllers. Likewise, put reusable spatial scopes on a SpatialQueries trait so the raw SQL lives in one file.Clickbar\Magellan\Geometry\... imports — a half-migrated codebase fails only when a cast is hydrated at runtime, which is exactly what tests won't cover if your suite mocks models.How can I help you explore Laravel packages today?