To run location-aware queries in Laravel on PostgreSQL, enable PostGIS, store each location as a typed geography(POINT,4326) column, index it with GiST, and filter nearby-location searches with ST_DWithin instead of computing distance in the WHERE clause. Laravel can declare the spatial column, but PostgreSQL decides whether the spatial index is used, so most of the architecture choices sit at the database boundary. The official PostGIS and Laravel pages do not publish a row count, latency target, or partitioning trigger, so any scaling decision has to rest on measurements from your own workload.
Confirm PostGIS is installed before you write a migration
A PostgreSQL geography column cannot exist until the PostGIS extension is available in the database. The Laravel 11.x migrations documentation states that PostgreSQL users must install PostGIS before using the geography method, so set the extension up first. Check the version you run against the framework’s migrations page: https://laravel.com/framework/docs/11.x/migrations.
As an Amazon Associate I earn from qualifying purchases.
- Check that the server ships PostGIS. Connect with
psqland run:SELECT name, default_version FROM pg_available_extensions WHERE name = 'postgis';A returned row means the extension files are installed. An empty result means the PostGIS package for your PostgreSQL major version must be installed on the server, or your hosting provider must enable it.
- Enable the extension in the application database with
CREATE EXTENSION IF NOT EXISTS postgis;. Run it as a role that is allowed to create extensions in that database. - Record the installed version with
SELECT PostGIS_Version();. Function availability and units are determined by the PostGIS version you deploy, so check examples against that version rather than against whichever manual page you read first. - Confirm that the connection in
config/database.phpuses thepgsqldriver. Laravel’s database documentation covers the connection options: https://laravel.com/framework/docs/database.
Choose geometry or geography for each location column
PostGIS provides two spatial types, and the choice changes what coordinates mean and what distance functions return. A geography column measures on the earth’s surface, and the standard example uses WGS84 (SRID 4326) longitude and latitude. A geometry column treats coordinates as points on a plane, so its measurements are in whatever units the column’s SRID defines. The PostGIS management chapter documents both types: https://www.postgis.net/docs/using_postgis_dbmanagement.html.
Free tools Windows power users keep installed
One-click scans. No signup required.
| Question | geography | geometry |
|---|---|---|
| Coordinate model | Earth-surface (spheroid-aware) measurements | Planar, in the units of the column’s SRID |
| Distance units from ST_Distance and ST_DWithin | Meters, for the standard geography functions | Units of the SRID; degrees when the SRID is 4326 |
| Function coverage | Narrower than geometry; confirm each function for your PostGIS version | Broadest set of functions and operators |
| Typical fit | Global point data, such as store or user locations, where distance in meters matters | Data in a local projected coordinate system, or workloads that need geometry operations |
For global point data, geography(POINT,4326) is the usual starting point. Avoid the common mistake of putting SRID 4326 coordinates in a geometry column and then passing a radius you think is in meters. The query runs, but the radius is interpreted in degrees.
#1 Best Overall
Create the table, the column and the GiST index
Keep the schema in a migration and keep the spatial SQL explicit. Laravel has spatial column methods, but the generated SQL and the exact method signature depend on the framework version, so the raw statements below are the safe baseline. The index is created on an empty table here, so a plain CREATE INDEX is fine.
<?php
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesDB;
use IlluminateSupportFacadesSchema;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
Schema::create('places', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
DB::statement('ALTER TABLE places ADD COLUMN location geography(POINT,4326) NOT NULL');
DB::statement('CREATE INDEX places_location_gist ON places USING GIST (location)');
}
public function down(): void
{
Schema::dropIfExists('places');
}
};
The GiST index is required for spatial search. The PostGIS FAQ explains that spatial columns use USING GIST and that a conventional B-tree index will not help spatial queries: https://postgis.net/documentation/faq/spatial-indexes/.
Write locations with bound parameters
Build the point in the order PostGIS expects, longitude first and latitude second. Swapping them stores a valid-looking point in the wrong place, and no error is raised for many coordinate pairs.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Rank #2
DB::insert(
'INSERT INTO places (name, location, created_at, updated_at)
VALUES (?, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, now(), now())',
[$name, $longitude, $latitude]
);
Write queries PostgreSQL can accelerate
Nearby places with ST_DWithin
ST_DWithin is the index-aware predicate for “within this distance”. PostGIS can use a bounding-box prefilter from the spatial index to narrow the candidate rows, then calculate the exact distance only for those candidates. With a geography column, the radius is in meters. The query below returns up to 20 places within the radius, closest first:
$nearby = DB::select(
'SELECT id, name, ST_Distance(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography) AS meters
FROM places
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)
ORDER BY meters
LIMIT 20',
[$longitude, $latitude, $longitude, $latitude, $radiusMeters]
);
The bindings follow the placeholder order: the select-list point first, then the point and radius in the WHERE clause. Once ST_DWithin has filtered the rows, computing distance in the select list and sorting by it is fine.
The distance filter that scans every row
Filtering on ST_Distance looks equivalent but does not give the planner an index-aware predicate. The PostGIS query chapter describes this pattern as computing the distance for each row, and ST_DWithin as the index-aware alternative: https://postgis.net/docs/manual-dev/en/using_postgis_query.html (the development manual; check the page against your deployed release).
Rank #3
-- Avoid as the main filter on a large table
WHERE ST_Distance(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography) < ?
-- Prefer
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)
Relationship predicates for zones and boundaries
For polygons such as delivery zones, ST_Intersects, ST_Contains and ST_Within are the relevant predicates, and they are index-aware. They are not interchangeable. A point lying exactly on a polygon’s boundary is matched by ST_Intersects but not by ST_Contains. Choose the predicate by that boundary rule, not by which name reads more naturally.
Ordering without a radius
Sorting by distance is safe after a radius filter. For nearest-neighbour searches without a radius, or for queries that combine distance with other filters, check the ordering operators the PostGIS query chapter documents for your column type, then read the query plan rather than assuming an index is used.
Choose the index type from data shape and update pattern
GiST is the starting point. Consider the alternatives only when your data layout or update pattern gives a reason, and compare them against your real queries.
Rank #4
GiST: the default choice
The PostGIS management chapter describes GiST as the most commonly used and most versatile spatial index. It handles mixed, unordered locations and ongoing inserts and updates without special maintenance. The cost is that it is generally larger and slower to build than BRIN for the same data, so measure index size and build time on a realistic copy of the table.
BRIN: only for spatially ordered, rarely changed data
BRIN can fit when rows are physically correlated with location, for example when data is loaded in spatial order, and when updates are infrequent. It stores a summary per range of table pages, so it is smaller and faster to build than GiST. The trade-offs are that it is lossy, so candidate rows are rechecked, and that new ranges are not summarized automatically. Run brin_summarize_new_values() or VACUUM after loads, and confirm which operator classes your PostGIS version offers for the column type before creating the index.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
SP-GiST: an alternative to test
SP-GiST is a partitioned search-tree index and is worth evaluating as an alternative. Official documentation does not rank it above GiST for general spatial workloads, so test it on your own data and query mix before adopting it, and confirm operator class support for your PostGIS version.
Best Value
Build indexes on a table that is already taking writes
A plain CREATE INDEX on an existing large table blocks writes for the duration of the build. CREATE INDEX CONCURRENTLY avoids that blocking at the cost of a slower build, and the PostGIS management chapter documents this approach:
CREATE INDEX CONCURRENTLY places_location_gist ON places USING GIST (location);
PostgreSQL does not allow CREATE INDEX CONCURRENTLY inside a transaction block. A migration runner that wraps each migration in a transaction will reject it, so confirm how your Laravel version wraps migrations and run the statement in a separate step if needed.
- If the build fails, PostgreSQL can leave an invalid index behind. Check with
SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE indrelid = 'places'::regclass;. Drop an invalid index withDROP INDEX CONCURRENTLY places_location_gist;and retry. - After the index exists and the table has loaded, run
VACUUM ANALYZE places;so the planner has current statistics.
Check the query plan and troubleshoot
- Run the real query under
EXPLAIN ANALYZEwith representative coordinates and radius, for example:EXPLAIN ANALYZE SELECT id FROM places WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(-0.1276, 51.5072), 4326)::geography, 1000); - Look for a scan that names
places_location_gist, such as a Bitmap Index Scan followed by a Bitmap Heap Scan onplaces. - Compare the timing lines with a run at production-like volume, not only with the small development table.
On a small table, PostgreSQL may choose a sequential scan even when the index is correct, because scanning a few pages is cheaper than using the index. That result alone does not indicate a faulty query.
- Sequential scan on a large table: confirm the predicate is
ST_DWithin, notST_Distance, that the index exists inpg_indexes, and thatVACUUM ANALYZEhas run. Also check that the indexed column is not wrapped in a function in theWHEREclause, such asST_Transform(location, ...), because that prevents the index from being used. - Points appear in the wrong place: longitude and latitude are swapped in
ST_MakePoint. - Radius is off by orders of magnitude: the column is
geometrywith SRID 4326, so the radius is in degrees. Switch the column togeographyor convert the radius to match the column’s units. - Type errors or unexpected matches: the point operand must match the column’s type. Cast it with
::geographyor::geometryto match.
Decide on scale from measurements
The PostGIS and Laravel documentation does not give a row count, latency target, or partitioning trigger, so this article does not provide one. Collect these measurements before you change the architecture:
- The current row count and monthly growth rate of the location table.
- The write rate for inserts and updates, and how often existing coordinates change.
- The distribution of query centres and radii, and the typical number of rows each query returns.
- The latency objective each query must meet, measured at the percentile your application uses.
- The index size, from
SELECT pg_size_pretty(pg_relation_size('places_location_gist'));, and theEXPLAIN ANALYZEtimings at production-like volume.
Partitioning or a separate spatial service becomes a question only after the schema, index type and query shape are correct and the plan still misses the latency objective. At that point, test each option with the same measurements rather than adopting it by default.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




