This guide provides a comprehensive walkthrough for implementing geospatial functionality in a Laravel application using PostgreSQL with PostGIS.
- Introduction
- Prerequisites
- Setting Up PostgreSQL with PostGIS
- Configuring Laravel for Spatial Data
- Creating Migrations with Spatial Columns
- Setting Up Eloquent Models
- Implementing Core Geospatial Techniques
- Frontend Integration
- Performance Optimization
- Common Issues and Solutions
- Useful Extensions and Packages
Geospatial functionality allows your application to work with location data, enabling features like:
- Finding nearby locations
- Determining if a point is within a defined area
- Calculating distances between points
- Creating geofences for location-based triggers
PostgreSQL, combined with its PostGIS extension, provides powerful geospatial capabilities that integrate well with Laravel.
- PHP 8.0+
- Laravel 8.0+
- PostgreSQL 12+
- PostGIS 3.0+
- Composer
For Ubuntu/Debian:
sudo apt update
sudo apt install postgresql postgresql-contrib
sudo apt install postgis postgresql-12-postgis-3For macOS with Homebrew:
brew install postgresql
brew install postgissudo -u postgres psqlCREATE DATABASE laravel_geo;
CREATE USER laravel_user WITH ENCRYPTED PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE laravel_geo TO laravel_user;
\c laravel_geoAfter connecting to your database:
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_topology;To verify PostGIS is installed correctly:
SELECT PostGIS_Version();Create a new Laravel project if you don't have one:
composer create-project laravel/laravel your-project-name
cd your-project-nameUpdate your .env file with PostgreSQL connection details:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel_geo
DB_USERNAME=laravel_user
DB_PASSWORD=your_password
There are several packages that help with geospatial functionality in Laravel:
composer require grimzy/laravel-mysql-spatialWhile this package has "mysql" in its name, it works with PostgreSQL as well.
Alternatively, you can use:
composer require matanyadaev/laravel-eloquent-spatialCreate migrations for your location-based tables:
php artisan make:migration create_locations_table
php artisan make:migration create_geofences_tableuse Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class CreateLocationsTable extends Migration
{
public function up()
{
Schema::create('locations', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
// Add Point column with PostGIS
DB::statement('ALTER TABLE locations ADD COLUMN coordinates GEOMETRY(Point, 4326)');
// Add spatial index
DB::statement('CREATE INDEX locations_coordinates_idx ON locations USING GIST (coordinates)');
}
public function down()
{
Schema::dropIfExists('locations');
}
}use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class CreateGeofencesTable extends Migration
{
public function up()
{
Schema::create('geofences', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
// Add Polygon column with PostGIS
DB::statement('ALTER TABLE geofences ADD COLUMN boundary GEOMETRY(Polygon, 4326)');
// Add spatial index
DB::statement('CREATE INDEX geofences_boundary_idx ON geofences USING GIST (boundary)');
}
public function down()
{
Schema::dropIfExists('geofences');
}
}Run your migrations:
php artisan migratephp artisan make:model LocationUpdate the model with spatial capabilities:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Grimzy\LaravelMysqlSpatial\Eloquent\SpatialTrait;
class Location extends Model
{
use HasFactory, SpatialTrait;
protected $fillable = [
'name',
'description',
'coordinates'
];
protected $spatialFields = [
'coordinates',
];
}php artisan make:model GeofenceUpdate the model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Grimzy\LaravelMysqlSpatial\Eloquent\SpatialTrait;
class Geofence extends Model
{
use HasFactory, SpatialTrait;
protected $fillable = [
'name',
'description',
'boundary'
];
protected $spatialFields = [
'boundary',
];
}php artisan make:controller LocationController
php artisan make:controller GeofenceController<?php
namespace App\Http\Controllers;
use App\Models\Location;
use App\Models\Geofence;
use Illuminate\Http\Request;
use Grimzy\LaravelMysqlSpatial\Types\Point;
class LocationController extends Controller
{
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
$location = Location::create([
'name' => $request->name,
'description' => $request->description,
'coordinates' => new Point($request->latitude, $request->longitude),
]);
return response()->json($location, 201);
}
public function nearby(Request $request)
{
$request->validate([
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
'distance' => 'required|numeric', // in meters
]);
$latitude = $request->latitude;
$longitude = $request->longitude;
$distance = $request->distance;
// Find locations within specified distance
$locations = Location::whereRaw("ST_DWithin(
coordinates::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
?
)", [$longitude, $latitude, $distance])
->get();
return response()->json($locations);
}
public function checkInGeofence(Request $request)
{
$request->validate([
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
$latitude = $request->latitude;
$longitude = $request->longitude;
// Find all geofences that contain this point
$geofences = Geofence::whereRaw("ST_Contains(
boundary,
ST_SetSRID(ST_Point(?, ?), 4326)
)", [$longitude, $latitude])
->get();
return response()->json([
'in_geofence' => $geofences->isNotEmpty(),
'geofences' => $geofences
]);
}
}<?php
namespace App\Http\Controllers;
use App\Models\Geofence;
use Illuminate\Http\Request;
use Grimzy\LaravelMysqlSpatial\Types\Polygon;
use Grimzy\LaravelMysqlSpatial\Types\LineString;
use Grimzy\LaravelMysqlSpatial\Types\Point;
class GeofenceController extends Controller
{
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'coordinates' => 'required|array',
'coordinates.*' => 'required|array',
'coordinates.*.*' => 'required|numeric',
]);
// Convert the coordinates to a polygon
$points = [];
foreach ($request->coordinates as $coord) {
$points[] = new Point($coord[1], $coord[0]); // lat, lng
}
// Add the first point again to close the polygon
if ($points[0]->getLat() !== end($points)->getLat() ||
$points[0]->getLng() !== end($points)->getLng()) {
$points[] = $points[0];
}
$lineString = new LineString($points);
$polygon = new Polygon([$lineString]);
$geofence = Geofence::create([
'name' => $request->name,
'description' => $request->description,
'boundary' => $polygon,
]);
return response()->json($geofence, 201);
}
public function getLocationsInGeofence($geofenceId)
{
$geofence = Geofence::findOrFail($geofenceId);
$locations = Location::whereRaw("ST_Contains(?, coordinates)", [$geofence->boundary])
->get();
return response()->json([
'geofence' => $geofence,
'locations' => $locations
]);
}
}In your routes/api.php file:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\LocationController;
use App\Http\Controllers\GeofenceController;
// Location routes
Route::post('/locations', [LocationController::class, 'store']);
Route::get('/locations/nearby', [LocationController::class, 'nearby']);
Route::get('/locations/in-geofence', [LocationController::class, 'checkInGeofence']);
// Geofence routes
Route::post('/geofences', [GeofenceController::class, 'store']);
Route::get('/geofences/{geofence}/locations', [GeofenceController::class, 'getLocationsInGeofence']);This technique determines if a geographic point (latitude, longitude) is inside a polygon area.
// In a controller or service:
public function isPointInArea($lat, $lng, $geofenceId)
{
$geofence = Geofence::findOrFail($geofenceId);
$isInside = DB::select("
SELECT ST_Contains(
boundary,
ST_SetSRID(ST_Point(?, ?), 4326)
) as is_inside
FROM geofences
WHERE id = ?
", [$lng, $lat, $geofenceId])[0]->is_inside;
return $isInside;
}Geofencing allows you to trigger actions when a point enters or exits a defined area.
// Check if a point is inside any registered geofence
public function checkGeofences($lat, $lng)
{
$matchingGeofences = Geofence::whereRaw("
ST_Contains(
boundary,
ST_SetSRID(ST_Point(?, ?), 4326)
)", [$lng, $lat]
)->get();
if ($matchingGeofences->isNotEmpty()) {
// Point is inside at least one geofence
// Trigger notification or action
foreach ($matchingGeofences as $fence) {
event(new EnteredGeofence($fence->id, $lat, $lng));
}
}
return $matchingGeofences;
}Find locations within a certain distance of a point:
// Get all locations within a specified distance
public function getNearbyLocations($lat, $lng, $distanceInMeters)
{
return Location::whereRaw("
ST_DWithin(
coordinates::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
?
)", [$lng, $lat, $distanceInMeters]
)->get();
}
// Calculate distance between two points
public function getDistance($lat1, $lng1, $lat2, $lng2)
{
// Returns distance in meters
$result = DB::select("
SELECT ST_Distance(
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography
) as distance", [$lng1, $lat1, $lng2, $lat2]
);
return $result[0]->distance;
}To ensure good performance with large datasets, spatial indexing is crucial:
// This was already included in our migrations, but here's the manual approach:
DB::statement('CREATE INDEX locations_coordinates_idx ON locations USING GIST (coordinates)');
DB::statement('CREATE INDEX geofences_boundary_idx ON geofences USING GIST (boundary)');Set up RESTful API endpoints for your geospatial features:
// In routes/api.php
Route::get('/locations/nearby', [LocationController::class, 'nearby']);
Route::post('/locations/check-geofence', [LocationController::class, 'checkInGeofence']);
Route::get('/locations/{location}/distance/{otherLocation}', [LocationController::class, 'getDistanceBetweenLocations']);// Get current location from browser
function getCurrentPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation is not supported by your browser'));
} else {
navigator.geolocation.getCurrentPosition(
position => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
},
() => {
reject(new Error('Unable to retrieve your location'));
}
);
}
});
}
// Check if user is in a geofence
async function checkGeofence() {
try {
const position = await getCurrentPosition();
const response = await fetch('/api/locations/check-geofence', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
latitude: position.latitude,
longitude: position.longitude
})
});
const data = await response.json();
if (data.in_geofence) {
console.log('You are in these geofences:', data.geofences);
} else {
console.log('You are not in any geofence.');
}
} catch (error) {
console.error('Error:', error);
}
}<!-- In your Blade template -->
<div id="map" style="height: 500px;"></div>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
<script>
// Initialize map
const map = L.map('map').setView([51.505, -0.09], 13);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Load locations from API
async function loadLocations() {
const response = await fetch('/api/locations');
const locations = await response.json();
locations.forEach(location => {
L.marker([location.coordinates.lat, location.coordinates.lng])
.addTo(map)
.bindPopup(location.name);
});
}
// Load geofences from API
async function loadGeofences() {
const response = await fetch('/api/geofences');
const geofences = await response.json();
geofences.forEach(geofence => {
const coordinates = geofence.boundary.coordinates[0].map(coord => [coord.lat, coord.lng]);
L.polygon(coordinates, {color: 'red'})
.addTo(map)
.bindPopup(geofence.name);
});
}
loadLocations();
loadGeofences();
</script>- Always use spatial indexes:
CREATE INDEX idx_locations_coordinates ON locations USING GIST (coordinates);- For frequent distance calculations, consider a materialized view:
CREATE MATERIALIZED VIEW common_distances AS
SELECT
l1.id as location1_id,
l2.id as location2_id,
ST_Distance(l1.coordinates::geography, l2.coordinates::geography) as distance
FROM locations l1, locations l2
WHERE l1.id < l2.id;
-- Refresh when needed
REFRESH MATERIALIZED VIEW common_distances;- Use the
::geographytype for accurate earth distance calculations:
$locations = Location::whereRaw("
ST_DWithin(
coordinates::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
?
)", [$lng, $lat, $distance]
)->get();For frequently requested queries:
// In a service class
public function getNearbyLocations($lat, $lng, $distance)
{
$cacheKey = "nearby_locations_{$lat}_{$lng}_{$distance}";
return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($lat, $lng, $distance) {
return Location::whereRaw("
ST_DWithin(
coordinates::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
?
)", [$lng, $lat, $distance]
)->get();
});
}Solution:
- Ensure spatial indexes are created
- Use
ST_DWithininstead of calculating distance for each record - Consider clustering your data based on geographic regions
Solution:
- Always use
::geographytype for distance calculations on Earth - Make sure coordinates are in the correct format (longitude, latitude) for
ST_Point
// Correct way for earth distance calculation
$distance = DB::select("
SELECT ST_Distance(
ST_SetSRID(ST_Point(?, ?), 4326)::geography,
ST_SetSRID(ST_Point(?, ?), 4326)::geography
) as distance", [$lng1, $lat1, $lng2, $lat2]
)[0]->distance;Solution:
- Check if polygons are defined clockwise
- Verify polygon is closed (first and last points match)
- Ensure coordinates are in the correct order (longitude, latitude) for PostGIS functions
- Laravel GeoJSON: Add GeoJSON support for Laravel models
composer require matanyadaev/laravel-eloquent-spatial- Laravel Geocoder: Add geocoding capabilities
composer require spatie/laravel-geocoderST_AsGeoJSON- Convert geometry to GeoJSONST_Buffer- Create a buffer around a geometryST_Intersection- Find where geometries intersectST_Union- Combine multiple geometriesST_Simplify- Simplify geometries (useful for complex polygons)
// In a controller
public function getGeoJson()
{
$locations = DB::select("
SELECT
id,
name,
ST_AsGeoJSON(coordinates) as geometry
FROM locations
");
$features = [];
foreach ($locations as $location) {
$geometry = json_decode($location->geometry);
$features[] = [
'type' => 'Feature',
'properties' => [
'id' => $location->id,
'name' => $location->name
],
'geometry' => $geometry
];
}
$geoJson = [
'type' => 'FeatureCollection',
'features' => $features
];
return response()->json($geoJson);
}This guide has provided a comprehensive walkthrough for implementing geospatial functionality in a Laravel application using PostgreSQL with PostGIS. By leveraging these powerful tools, you can build location-aware applications that efficiently handle complex spatial operations.
Always remember:
- Use spatial indexes for performance
- Convert to geography type for accurate Earth calculations
- Structure your data to take advantage of PostGIS's capabilities
- Cache results from expensive spatial queries
For more advanced use cases, explore the full range of PostGIS functions and consider scaling options like database sharding based on geographic regions if your application needs to handle extremely large datasets.