creof/doctrine2-spatial
Doctrine2 spatial extension that adds geometry and geography data types and DQL spatial functions to Doctrine ORM, making it easy to store and query points, lines, and polygons with MySQL or PostgreSQL/PostGIS. Supports distance, bounding-box, and other spatial operations in Laravel and plain PHP pr...
creof/doctrine2-spatial is a Doctrine DBAL/ORM extension, not a Laravel package. It ships:
point, linestring, polygon, multipoint, multilinestring, multipolygon, geometrycollection, geometry) that convert between raw DB geometry columns and CrEOF\Spatial\PHP\Types\Geometry\* PHP objects.ST_GeomFromWKB / AsBinary, etc.).ST_Contains, ST_Within, ST_Distance, ST_Intersects, ST_AsText, …) with separate MySQL and PostgreSQL implementations.Minimum setup in a Laravel app:
composer require creof/doctrine2-spatial
# plus a Doctrine integration if you don't already have one
composer require laravel-doctrine/orm
Register the types (DBAL's type registry is global static state, so guard it):
// app/Providers/SpatialServiceProvider.php
use Doctrine\DBAL\Types\Type;
public function boot(): void
{
$types = [
'geometry' => \CrEOF\Spatial\DBAL\Types\GeometryType::class,
'point' => \CrEOF\Spatial\DBAL\Types\Geometry\PointType::class,
'linestring' => \CrEOF\Spatial\DBAL\Types\Geometry\LineStringType::class,
'polygon' => \CrEOF\Spatial\DBAL\Types\Geometry\PolygonType::class,
'multipoint' => \CrEOF\Spatial\DBAL\Types\Geometry\MultiPointType::class,
'multilinestring' => \CrEOF\Spatial\DBAL\Types\Geometry\MultiLineStringType::class,
'multipolygon' => \CrEOF\Spatial\DBAL\Types\Geometry\MultiPolygonType::class,
'geometrycollection' => \CrEOF\Spatial\DBAL\Types\Geometry\GeometryCollectionType::class,
];
foreach ($types as $name => $class) {
if (! Type::hasType($name)) {
Type::addType($name, $class);
}
}
}
Then register the DQL functions on the ORM Configuration (see Gotchas — the Laravel config buckets are only lookup categories):
public function boot(\Doctrine\ORM\EntityManagerInterface $em): void
{
$config = $em->getConfiguration();
$registered = $config->getCustomStringFunctions();
// Pick the set matching your driver — MySQL classes won't work on PostGIS
$namespace = config('database.default') === 'pgsql'
? 'CrEOF\\Spatial\\ORM\\Query\\AST\\Functions\\Postgresql\\'
: 'CrEOF\\Spatial\\ORM\\Query\\AST\\Functions\\MySql\\';
foreach (['ST_Contains', 'ST_Within', 'ST_Distance', 'ST_Intersects', 'ST_AsText'] as $fn) {
if (! isset($registered[$fn])) {
$class = $namespace . str_replace('_', '', $fn); // STContains, STDistance, ...
$config->addCustomStringFunction($fn, $class);
}
}
}
Your first use case is almost always "store a point, query by proximity or containment":
use CrEOF\Spatial\PHP\Types\Geometry\Point;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Venue
{
#[ORM\Column(type: 'point', nullable: true)]
private ?Point $location = null;
public function setLocation(float $lng, float $lat): void
{
$this->location = new Point($lng, $lat); // x = lng, y = lat
}
}
$dql = 'SELECT v FROM Venue v WHERE ST_Distance(v.location, :p) < 5000';
$query = $em->createQuery($dql)
->setParameter('p', new Point(13.4050, 52.5200), 'point'); // note the type hint
$nearby = $query->getResult();
Where to look first in vendor/creof/doctrine2-spatial:
src/CrEOF/Spatial/DBAL/Types/GeometryType.php — conversion logic, SRID handling.src/CrEOF/Spatial/DBAL/Platform/{MySQL,PostgreSQL}/ — what SQL is actually generated.src/CrEOF/Spatial/ORM/Query/AST/Functions/{MySql,Postgresql}/ — the exact function names and class names available.Keep spatial columns on the Doctrine entity manager, not Eloquent. Eloquent's query builder has no concept of the point type and will happily bind a Point object as a string (or [object Object]). Put geo-aware models behind Doctrine repositories and expose them to the rest of the app through plain DTOs.
Repository pattern for spatial predicates. DQL functions don't compose well with fluent query builders, so isolate them:
class VenueRepository
{
public function withinPolygon(Polygon $area): array
{
return $this->em->createQuery(
'SELECT v FROM Venue v WHERE ST_Within(v.location, :area) = true'
)->setParameter('area', $area, 'polygon')->getResult();
}
}
Two-tier query strategy for anything resembling "near me". Use an index-friendly bounding-box prefilter, then the exact predicate:
-- MySQL
WHERE MBRContains(ST_GeomFromText(:box, 4326), location)
AND ST_Distance_Sphere(location, ST_GeomFromText(:p, 4326)) < 5000
-- PostGIS
WHERE location && ST_MakeEnvelope(:minLng, :minLat, :maxLng, :maxLat, 4326)
AND ST_DWithin(location::geography, ST_MakePoint(:lng, :lat)::geography, 5000)
Escape hatch: native SQL through the DBAL connection. When a function isn't wrapped by the package (or the DQL parser chokes), drop to $em->getConnection()->fetchAllAssociative() with WKT parameters. This is often less painful than fighting the AST:
$rows = $em->getConnection()->fetchAllAssociative(
'SELECT id, ST_AsText(location) AS wkt FROM venues WHERE ST_Distance_Sphere(location, ST_GeomFromText(:p, 4326)) < :r',
['p' => 'POINT(13.405 52.52)', 'r' => 5000]
);
Serialization boundary. Point/Polygon objects aren't JSON-serializable. Convert explicitly in an API resource:
return [
'id' => $venue->getId(),
'lng' => $venue->getLocation()?->getX(),
'lat' => $venue->getLocation()?->getY(),
];
…or emit WKT/GeoJSON and let the frontend deal with it, remembering GeoJSON is [lng, lat] while most JS map libs want [lat, lng].
Migrations. Laravel's schema builder doesn't know spatial types. Use raw statements (and pair them with the Doctrine column type so hydration matches):
Schema::table('venues', function (Blueprint $table) {
$table->point('location')->nullable(); // if your builder supports it, otherwise:
});
DB::statement('ALTER TABLE venues ADD location POINT NOT NULL SRID 4326');
DB::statement('CREATE SPATIAL INDEX venues_location_spidx ON venues (location)');
Testing. Don't use SQLite in-memory — the package targets MySQL/PostGIS. Run a MySQL or PostGIS service container in CI and assert round-trips with ST_AsText(...) rather than comparing binary blobs.
This package is effectively unmaintained (last release 2017). It targets DBAL 2.x / ORM 2.x. On a modern Laravel app you will hit Type::getName() deprecations, renamed platform classes (MySqlPlatform vs MySQLPlatform), and signature changes in AbstractPlatform. Practical options: pin doctrine/dbal:^2.13 and doctrine/orm:^2.14 alongside laravel-doctrine/orm:^1.7, or use a maintained fork / a lighter alternative (mjaschen/doctrine-point-type for points only, or brick/geo values mapped manually). Decide this before you write schema.
Always pass the type name when binding geometry parameters. setParameter('p', new Point(...)) without 'point' makes DBAL bind the object as a string and you'll get conversion errors or silently malformed SQL. Same on hydration: the column's declared DBAL type name must exactly match the registered name (a column declared point mapped with type: 'geometry' will blow up).
DQL functions are platform-specific. Functions\MySql\STContains emits MySQL syntax; running it against PostGIS produces invalid SQL. Register conditionally per connection. Also, laravel-doctrine/orm's custom_string_functions / custom_numeric_functions / custom_datetime_functions config arrays are just name→class lookup buckets — the parser doesn't care which one you use — but you must also re-register if you add a second entity manager.
SRID mismatch is the #1 silent-wrong-results bug. MySQL columns without SRID 4326 treat ST_Distance as degrees; PostGIS returns metres only for geography casts. Pick one convention (I recommend storing 4326 everywhere, computing distances in metres) and enforce it in migrations. new Point($x, $y, $srid) accepts an SRID — use it consistently.
Spatial indexes have hard prerequisites. MySQL requires the geometry column to be NOT NULL for SPATIAL INDEX, and the index only helps for MBRContains/MBRWithin-style predicates — WHERE ST_Distance(...) < x always full-scans. PostGIS needs an explicit USING GIST index. Plan the bounding-box prefilter into the query from day one.
Global static state and long-running workers. Type::addType() mutates a process-wide registry; registering twice throws. Guard with Type::hasType(), and do it in boot() (not register()) so Octane workers and the test suite don't re-register per request. If you use parallel test processes, this is per-process and safe; within one process, it isn't.
Debugging geometry columns. A raw SELECT * gives you binary WKB and looks like garbage. Always add ST_AsText(col) AS wkt when eyeballing data, and log $query->getDQL() + $query->getSQL() to see whether your function was parsed as expected (an unregistered function usually fails at parse time with [Syntax Error] line 0, col ...).
Schema tooling will fight you. doctrine:schema:validate / doctrine:migrations:diff often want to "fix" spatial columns because the platform type hints don't match the introspection output. Either exclude those tables from diffing or manage spatial DDL by hand and keep the Doctrine mapping in sync manually.
Extension points. The cleanest way to add a missing function is to extend CrEOF\Spatial\ORM\Query\AST\Functions\AbstractFunction (or copy the nearest MySQL/Postgres implementation) and register it like the rest. For a custom column type (e.g. a constrained geography type), subclass the relevant GeometryType and register under a new name. Both are stable public surface; the internals of the platform shims are not — avoid overriding them unless you're already pinned to a specific DBAL version.
How can I help you explore Laravel packages today?