← Volver al blog

Búsqueda geolocalizada con Elasticsearch y Laravel

2026-06-14

Búsqueda geolocalizada con Elasticsearch y Laravel

La búsqueda por proximidad geográfica es un requisito cada vez más común en aplicaciones modernas: desde marketplaces de delivery hasta directorios de negocios, pasando por apps de movilidad. Implementar este tipo de funcionalidad con consultas SQL tradicionales escala mal cuando los volúmenes de datos crecen. Elasticsearch ofrece capacidades geoespaciales nativas que, combinadas con Laravel, permiten construir sistemas de búsqueda rápidos y precisos.

En este artículo veremos cómo implementar una búsqueda geolocalizada completa: desde la indexación de puntos geográficos hasta consultas con filtros de distancia, ordenamiento por proximidad e integración con mapas.

¿Por qué Elasticsearch para geolocalización?

Antes de entrar en código, vale la pena entender las ventajas de Elasticsearch frente a alternativas como PostGIS o consultas con la fórmula de Haversine en MySQL:

Configuración del entorno

Asumimos que ya tenés Laravel 10+ con Elasticsearch 8.x funcionando. Necesitás el cliente oficial de Elasticsearch para PHP:

composer require elasticsearch/elasticsearch

Configuramos la conexión en un service provider o directamente donde la necesitemos:

use Elasticsearch\ClientBuilder;

$client = ClientBuilder::create()
    ->setHosts([env('ELASTICSEARCH_HOST', 'http://localhost:9200')])
    ->build();

Modelando datos geográficos

Para este ejemplo, indexaremos negocios con ubicación. El punto clave es usar el tipo geo_point en el mapping de Elasticsearch.

Primero, la migración de Laravel:

Schema::create('businesses', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->string('category');
    $table->decimal('latitude', 10, 8);
    $table->decimal('longitude', 11, 8);
    $table->json('attributes')->nullable(); // horarios, servicios, etc.
    $table->timestamps();
});

El mapping de Elasticsearch debe definirse explícitamente antes de indexar:

$params = [
    'index' => 'businesses',
    'body' => [
        'mappings' => [
            'properties' => [
                'name' => ['type' => 'text', 'analyzer' => 'spanish'],
                'description' => ['type' => 'text', 'analyzer' => 'spanish'],
                'category' => ['type' => 'keyword'],
                'location' => ['type' => 'geo_point'],
                'attributes' => ['type' => 'object'],
                'created_at' => ['type' => 'date']
            ]
        ]
    ]
];

$client->indices()->create($params);

Notá que geo_point espera el campo location con formato específico. Laravel no tiene soporte nativo para geo_point, así que transformamos los datos en el mapper.

Indexación con transformación de coordenadas

Creamos un command o job para sincronizar negocios. La lógica central es transformar latitude/longitude en el formato que Elasticsearch requiere:

namespace App\Services;

use App\Models\Business;
use Elasticsearch\Client;

class BusinessIndexer
{
    public function __construct(private Client $client) {}

    public function index(Business $business): void
    {
        $this->client->index([
            'index' => 'businesses',
            'id' => $business->id,
            'body' => [
                'name' => $business->name,
                'description' => $business->description,
                'category' => $business->category,
                'location' => [
                    'lat' => (float) $business->latitude,
                    'lon' => (float) $business->longitude
                ],
                'attributes' => $business->attributes,
                'created_at' => $business->created_at->toIso8601String()
            ]
        ]);
    }

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

        foreach ($businesses as $business) {
            $params['body'][] = [
                'index' => [
                    '_index' => 'businesses',
                    '_id' => $business->id
                ]
            ];
            $params['body'][] = [
                'name' => $business->name,
                'description' => $business->description,
                'category' => $business->category,
                'location' => [
                    'lat' => (float) $business->latitude,
                    'lon' => (float) $business->longitude
                ],
                'attributes' => $business->attributes,
                'created_at' => $business->created_at->toIso8601String()
            ];
        }

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

El bulk index es esencial para cargas iniciales. Con 100.000 negocios, la diferencia es dramatica: minutos versus segundos.

Búsqueda por proximidad: el corazón del sistema

La consulta fundamental es geo_distance, que filtra documentos dentro de un radio. Pero para una búsqueda completa, combinamos múltiples criterios:

namespace App\Services;

class BusinessSearchService
{
    public function __construct(private Client $client) {}

    public function searchNearby(
        float $lat,
        float $lon,
        int $radiusKm = 5,
        ?string $category = null,
        ?string $query = null,
        array $sort = []
    ): array {
        $must = [];
        $filter = [];

        // Filtro de distancia obligatorio
        $filter[] = [
            'geo_distance' => [
                'distance' => "{$radiusKm}km",
                'location' => [
                    'lat' => $lat,
                    'lon' => $lon
                ]
            ]
        ];

        // Filtro por categoría
        if ($category) {
            $filter[] = ['term' => ['category' => $category]];
        }

        // Búsqueda textual en nombre y descripción
        if ($query) {
            $must[] = [
                'multi_match' => [
                    'query' => $query,
                    'fields' => ['name^3', 'description'],
                    'type' => 'best_fields',
                    'fuzziness' => 'AUTO'
                ]
            ];
        }

        $params = [
            'index' => 'businesses',
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => $must,
                        'filter' => $filter
                    ]
                ],
                'sort' => $this->buildSort($sort, $lat, $lon),
                'from' => 0,
                'size' => 20
            ]
        ];

        $response = $this->client->search($params);

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

    private function buildSort(array $sort, float $lat, float $lon): array
    {
        $defaultSort = [
            '_geo_distance' => [
                'location' => [
                    'lat' => $lat,
                    'lon' => $lon
                ],
                'order' => 'asc',
                'unit' => 'km',
                'mode' => 'min'
            ]
        ];

        // Permitir override: relevancia primero, distancia despues
        return match ($sort['by'] ?? 'distance') {
            'relevance' => ['_score' => 'desc', $defaultSort],
            'distance' => [$defaultSort, '_score' => 'desc'],
            default => [$defaultSort]
        };
    }

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

La clave está en _geo_distance como criterio de ordenamiento. Elasticsearch calcula la distancia exacta y la devuelve en sort, que usamos para mostrar "a 1.2 km".

Controlador Laravel y respuesta para mapas

Exponemos la búsqueda via API, con formato geojson-friendly para consumo directo en mapas:

namespace App\Http\Controllers\Api;

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

class NearbySearchController extends Controller
{
    public function __construct(private BusinessSearchService $searchService) {}

    public function __invoke(Request $request)
    {
        $validated = $request->validate([
            'lat' => ['required', 'numeric', 'between:-90,90'],
            'lon' => ['required', 'numeric', 'between:-180,180'],
            'radius' => ['nullable', 'integer', 'min:1', 'max:50'],
            'category' => ['nullable', 'string'],
            'q' => ['nullable', 'string', 'min:2'],
            'sort_by' => ['nullable', 'in:distance,relevance']
        ]);

        $results = $this->searchService->searchNearby(
            lat: (float) $validated['lat'],
            lon: (float) $validated['lon'],
            radiusKm: $validated['radius'] ?? 5,
            category: $validated['category'] ?? null,
            query: $validated['q'] ?? null,
            sort: ['by' => $validated['sort_by'] ?? 'distance']
        );

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

Integración con mapas: Leaflet

Del lado del cliente, recibimos GeoJSON y renderizamos con Leaflet. La coordenada de búsqueda puede venir del GPS del usuario o de un geocoder:

<div id="map" style="height: 500px;"></div>

<script>
const map = L.map('map').setView([-34.6037, -58.3816], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

async function searchNearby(lat, lon) {
    const params = new URLSearchParams({ lat, lon, radius: 3 });
    const response = await fetch(`/api/nearby?${params}`);
    const geojson = await response.json();

    const layer = L.geoJSON(geojson, {
        pointToLayer: (feature, latlng) => {
            return L.circleMarker(latlng, {
                radius: 8,
                fillColor: '#2563eb',
                color: '#fff',
                weight: 2,
                opacity: 1,
                fillOpacity: 0.8
            });
        },
        onEachFeature: (feature, layer) => {
            const props = feature.properties;
            layer.bindPopup(`
                <strong>${props.name}</strong><br>
                ${props.category} · ${props.distance_km} km
            `);
        }
    }).addTo(map);

    map.fitBounds(layer.getBounds(), { padding: [50, 50] });
}

// Usar geolocalización del navegador
navigator.geolocation.getCurrentPosition(
    pos => searchNearby(pos.coords.latitude, pos.coords.longitude),
    err => console.error('GPS no disponible', err)
);
</script>

Búsquedas avanzadas: bounding box y polígonos

Para interfaces de mapa donde el usuario navega, es más eficiente buscar por viewport que por centro+radio. Elasticsearch soporta geo_bounding_box:

$filter[] = [
    'geo_bounding_box' => [
        'location' => [
            'top_left' => [
                'lat' => $northEastLat,
                'lon' => $southWestLon
            ],
            'bottom_right' => [
                'lat' => $southWestLat,
                'lon' => $northEastLon
            ]
        ]
    ]
];

Para áreas irregulares (barrios, zonas de delivery), usá geo_polygon con los vértices del polígono.

Consideraciones de performance

Sincronización de datos

Mantené Elasticsearch sincronizado con MySQL usando eventos de modelo:

class Business extends Model
{
    protected $casts = [
        'attributes' => 'array',
        'latitude' => 'float',
        'longitude' => 'float'
    ];

    protected static function booted(): void
    {
        static::created(fn ($b) => app(BusinessIndexer::class)->index($b));
        static::updated(fn ($b) => app(BusinessIndexer::class)->index($b));
        static::deleted(fn ($b) => app(BusinessIndexer::class)->delete($b->id));
    }
}

Para alta disponibilidad, usá jobs en queue para la indexación y manejá fallos con retries.

Conclusión

La combinación de Elasticsearch con Laravel permite implementar búsqueda geolocalizada profesional sin complejidad excesiva. Los puntos críticos son: definir correctamente el mapping geo_point, transformar las coordenadas al formato esperado, y combinar filtros geoespaciales con criterios de negocio en consultas bool.

La arquitectura presentada escala desde miles hasta millones de documentos, y la API GeoJSON facilita la integración con cualquier librería de mapas moderna. Para producción, no olvidés monitorear los tiempos de query de Elasticsearch y ajustar sharding según tu volumen de datos.