← Back to blog

Geo-located search with Elasticsearch and Laravel

2026-06-14

Implementing Geo-located Search with Elasticsearch and Laravel

Location-based search has become a fundamental requirement for modern applications. Whether you are building a restaurant finder, a real estate platform, or a delivery tracking system, users expect to discover relevant results based on their physical proximity. This guide walks you through implementing geo-located search using Elasticsearch and Laravel, two technologies that pair exceptionally well for high-performance spatial queries.

Why Elasticsearch for Geospatial Search?

Elasticsearch provides robust geospatial capabilities out of the box. Unlike relational databases that struggle with complex distance calculations at scale, Elasticsearch uses inverted indices optimized for geo-queries. The engine supports multiple geo data types including geo_point for latitude-longitude pairs and geo_shape for complex polygons.

Key advantages of Elasticsearch for location search include:

Setting Up Your Laravel Environment

Before diving into mapping and queries, you need to establish connectivity between Laravel and Elasticsearch. The most mature package for this integration is elastic/elasticsearch-php, though many developers prefer babenkoivan/scout-elasticsearch-driver for its Laravel Scout compatibility.

Install the official Elasticsearch PHP client via Composer:

composer require elasticsearch/elasticsearch

Create a service provider to manage your Elasticsearch client instance:

<?php

namespace App\Providers;

use Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;

class ElasticsearchServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton('elasticsearch', function () {
            return ClientBuilder::create()
                ->setHosts(config('services.elasticsearch.hosts'))
                ->build();
        });
    }
}

Register this provider in your config/app.php and add connection details to config/services.php:

'elasticsearch' => [
    'hosts' => [
        env('ELASTICSEARCH_HOST', 'http://localhost:9200')
    ]
],

Designing the Index Mapping for Geo Data

Proper mapping is critical for geospatial functionality. You must explicitly define fields as geo_point type; Elasticsearch will not infer this automatically from standard lat/lon data.

Consider a business directory application where each document represents a venue with a physical location:

<?php

namespace App\Services;

class VenueIndexManager
{
    protected $client;

    public function __construct($client)
    {
        $this->client = $client;
    }

    public function createIndex(): void
    {
        $this->client->indices()->create([
            'index' => 'venues',
            'body' => [
                'mappings' => [
                    'properties' => [
                        'name' => [
                            'type' => 'text',
                            'analyzer' => 'standard'
                        ],
                        'category' => [
                            'type' => 'keyword'
                        ],
                        'description' => [
                            'type' => 'text'
                        ],
                        'location' => [
                            'type' => 'geo_point'
                        ],
                        'rating' => [
                            'type' => 'float'
                        ],
                        'price_range' => [
                            'type' => 'integer'
                        ],
                        'created_at' => [
                            'type' => 'date',
                            'format' => 'yyyy-MM-dd HH:mm:ss'
                        ]
                    ]
                ],
                'settings' => [
                    'number_of_shards' => 1,
                    'number_of_replicas' => 0
                ]
            ]
        ]);
    }
}

The geo_point type accepts multiple formats. You can store coordinates as an object with lat and lon properties, as a string in "lat,lon" format, as a geohash, or as an array [lon, lat]. The array format follows the GeoJSON convention where longitude precedes latitude.

Indexing Documents with Location Data

When indexing documents from Laravel, ensure your Eloquent models or data transfer objects properly format the location field. Here is a practical example using a repository pattern:

<?php

namespace App\Repositories;

use App\Models\Venue;

class VenueElasticsearchRepository
{
    protected $client;

    public function __construct($client)
    {
        $this->client = $client;
    }

    public function index(Venue $venue): void
    {
        $this->client->index([
            'index' => 'venues',
            'id' => $venue->id,
            'body' => [
                'name' => $venue->name,
                'category' => $venue->category,
                'description' => $venue->description,
                'location' => [
                    'lat' => $venue->latitude,
                    'lon' => $venue->longitude
                ],
                'rating' => $venue->rating,
                'price_range' => $venue->price_range,
                'created_at' => $venue->created_at->format('Y-m-d H:i:s')
            ]
        ]);
    }

    public function bulkIndex(array $venues): void
    {
        $params = ['body' => []];

        foreach ($venues as $venue) {
            $params['body'][] = [
                'index' => [
                    '_index' => 'venues',
                    '_id' => $venue->id
                ]
            ];

            $params['body'][] = [
                'name' => $venue->name,
                'category' => $venue->category,
                'location' => [
                    'lat' => $venue->latitude,
                    'lon' => $venue->longitude
                ],
                'rating' => $venue->rating,
                'price_range' => $venue->price_range
            ];
        }

        $this->client->bulk($params);
    }
}

For production applications, schedule bulk indexing during low-traffic periods and implement incremental updates for new or modified records.

Executing Geo Distance Queries

The most common geospatial query filters results within a specified radius of a point. Elasticsearch provides the geo_distance query for this purpose.

Build a search service that handles location-based filtering with additional business logic:

<?php

namespace App\Services;

class VenueSearchService
{
    protected $client;

    public function __construct($client)
    {
        $this->client = $client;
    }

    public function searchNearby(
        float $latitude,
        float $longitude,
        ?string $category = null,
        ?float $maxDistanceKm = 10,
        ?int $minRating = null,
        string $sortBy = 'distance'
    ): array {
        $mustQueries = [];

        $filterQueries = [
            [
                'geo_distance' => [
                    'distance' => sprintf('%skm', $maxDistanceKm),
                    'location' => [
                        'lat' => $latitude,
                        'lon' => $longitude
                    ]
                ]
            ]
        ];

        if ($category) {
            $filterQueries[] = [
                'term' => [
                    'category' => $category
                ]
            ];
        }

        if ($minRating) {
            $filterQueries[] = [
                'range' => [
                    'rating' => [
                        'gte' => $minRating
                    ]
                ]
            ];
        }

        $sort = [];

        if ($sortBy === 'distance') {
            $sort[] = [
                '_geo_distance' => [
                    'location' => [
                        'lat' => $latitude,
                        'lon' => $longitude
                    ],
                    'order' => 'asc',
                    'unit' => 'km',
                    'mode' => 'min'
                ]
            ];
        } elseif ($sortBy === 'rating') {
            $sort[] = [
                'rating' => [
                    'order' => 'desc'
                ]
            ];
        }

        $response = $this->client->search([
            'index' => 'venues',
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => $mustQueries,
                        'filter' => $filterQueries
                    ]
                ],
                'sort' => $sort,
                'size' => 50
            ]
        ]);

        return $this->transformResults($response, $latitude, $longitude);
    }

    protected function transformResults(array $response, float $lat, float $lon): array
    {
        return array_map(function ($hit) {
            return [
                'id' => $hit['_id'],
                'name' => $hit['_source']['name'],
                'category' => $hit['_source']['category'],
                'rating' => $hit['_source']['rating'],
                'location' => $hit['_source']['location'],
                'distance_km' => round($hit['sort'][0], 2),
                'score' => $hit['_score']
            ];
        }, $response['hits']['hits']);
    }
}

Notice how the _geo_distance sort option automatically calculates and returns the distance for each result. This value appears in the sort array of each hit, saving you from manual Haversine calculations in PHP.

Building Bounding Box Queries for Map Interfaces

Map applications typically require viewport-based searching rather than radial distance. When users pan or zoom a map, extract the visible bounds and query with a geo_bounding_box filter:

public function searchInViewport(
    float $topLeftLat,
    float $topLeftLon,
    float $bottomRightLat,
    float $bottomRightLon,
    ?string $category = null
): array {
    $filterQueries = [
        [
            'geo_bounding_box' => [
                'location' => [
                    'top_left' => [
                        'lat' => $topLeftLat,
                        'lon' => $topLeftLon
                    ],
                    'bottom_right' => [
                        'lat' => $bottomRightLat,
                        'lon' => $bottomRightLon
                    ]
                ]
            ]
        ]
    ];

    if ($category) {
        $filterQueries[] = [
            'term' => ['category' => $category]
        ];
    }

    $response = $this->client->search([
        'index' => 'venues',
        'body' => [
            'query' => [
                'bool' => [
                    'filter' => $filterQueries
                ]
            ],
            'size' => 500
        ]
    ]);

    return $this->transformViewportResults($response);
}

Integrating with Frontend Maps

The search results integrate cleanly with mapping libraries like Leaflet or Mapbox. Return GeoJSON-compatible structures from your Laravel controller:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\VenueSearchService;
use Illuminate\Http\Request;

class MapSearchController extends Controller
{
    protected $searchService;

    public function __construct(VenueSearchService $searchService)
    {
        $this->searchService = $searchService;
    }

    public function nearby(Request $request)
    {
        $validated = $request->validate([
            'lat' => 'required|numeric|between:-90,90',
            'lon' => 'required|numeric|between:-180,180',
            'radius' => 'nullable|numeric|min:0.1|max:50',
            'category' => 'nullable|string|max:50'
        ]);

        $results = $this->searchService->searchNearby(
            $validated['lat'],
            $validated['lon'],
            $validated['category'] ?? null,
            $validated['radius'] ?? 5
        );

        return response()->json([
            'type' => 'FeatureCollection',
            'features' => array_map(function ($venue) {
                return [
                    'type' => 'Feature',
                    'geometry' => [
                        'type' => 'Point',
                        'coordinates' => [
                            $venue['location']['lon'],
                            $venue['location']['lat']
                        ]
                    ],
                    'properties' => [
                        'id' => $venue['id'],
                        'name' => $venue['name'],
                        'distance_km' => $venue['distance_km'],
                        'rating' => $venue['rating']
                    ]
                ];
            }, $results)
        ]);
    }
}

Performance Optimization Strategies

Geospatial queries can become expensive without proper optimization. Implement these practices for production workloads:

Handling Edge Cases

Production geospatial search requires handling several edge cases. The poles and the International Date Line introduce coordinate discontinuities. Elasticsearch 7.x and later handle the date line correctly in geo_distance queries, but bounding boxes crossing it need explicit handling.

Validate coordinates before indexing:

public function validateCoordinates(?float $latitude, ?float $longitude): void
{
    if ($latitude === null || $longitude === null) {
        throw new InvalidArgumentException('Both latitude and longitude are required');
    }

    if ($latitude < -90 || $latitude > 90) {
        throw new InvalidArgumentException('Latitude must be between -90 and 90');
    }

    if ($longitude < -180 || $longitude > 180) {
        throw new InvalidArgumentException('Longitude must be between -180 and 180');
    }
}

Conclusion

Elasticsearch and Laravel provide a powerful combination for geo-located search. The key to success lies in proper index mapping, efficient query construction, and clean separation between your search logic and application layers. Start with simple geo_distance filters, expand to viewport-based bounding boxes as your mapping interface develops, and continuously monitor query performance as your dataset grows.

By leveraging Elasticsearch's native geospatial capabilities rather than implementing distance calculations in application code, you gain scalability and precision while keeping your Laravel codebase maintainable and clean.