Elasticsearch y PHP: construyendo una capa de búsqueda de alto rendimiento para aplicaciones Laravel desde cero en 2026
Introducción: cuándo necesitas Elasticsearch junto a MySQL
MySQL y PostgreSQL funcionan perfectamente para consultas relacionales, transacciones y almacenamiento de datos estructurados. Pero en el momento en que el usuario empieza a escribir en un campo de búsqueda, la situación cambia. LIKE '%query%' no escala, los índices FULLTEXT de MySQL ofrecen una relevancia deficiente y no soportan sinónimos, morfología ni filtrado facetado sin grandes esfuerzos.
Elasticsearch, el motor de búsqueda distribuido basado en Apache Lucene, cubre exactamente esa brecha. En 2026 sigue siendo el estándar de facto para construir la capa de búsqueda en aplicaciones Laravel de alta carga: marketplaces, agregadores de noticias y plataformas SaaS con catálogos de productos.
En este artículo recorremos el camino desde cero: configuraremos el cliente, diseñaremos los índices, implementaremos la búsqueda con filtrado y ordenación, construiremos la sincronización de datos mediante colas y cachearemos las consultas con Redis.
Arquitectura de integración: sincronización, indexación y búsqueda
La regla clave es: Elasticsearch es un almacenamiento secundario. MySQL o PostgreSQL siguen siendo la fuente de verdad. Elasticsearch almacena «proyecciones» desnormalizadas de documentos, optimizadas para la búsqueda.
El flujo de datos típico es el siguiente:
- El usuario crea o actualiza un registro a través de la aplicación Laravel.
- Un evento de Eloquent (
created,updated,deleted) dispara un Observer. - El Observer envía una tarea a la cola (Redis + Laravel Queue).
- El worker ejecuta la tarea: forma el documento y lo indexa en Elasticsearch.
- En la búsqueda, la aplicación consulta directamente Elasticsearch, obtiene los ID de los documentos y, si es necesario, carga los registros completos desde la base de datos.
Este enfoque protege contra la ralentización de las consultas principales y aísla la capa de búsqueda de la lógica de negocio.
Configuración de Elasticsearch y el cliente en Laravel
Instalamos el cliente PHP oficial de Elasticsearch 8.x:
composer require elastic/elasticsearch
Creamos un service provider y registramos el cliente en el contenedor:
<?php
namespace App\Providers;
use Elastic\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.host')])
->setBasicAuthentication(
config('services.elasticsearch.user'),
config('services.elasticsearch.password')
)
->build();
});
}
}
Añadimos la configuración en config/services.php:
'elasticsearch' => [
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
'user' => env('ELASTICSEARCH_USER', 'elastic'),
'password' => env('ELASTICSEARCH_PASSWORD', 'secret'),
],
Registramos el provider en bootstrap/providers.php (Laravel 11+) o en config/app.php.
Diseño de índices: mapeo, analizadores y campos
Un mapeo correcto es la base de una búsqueda de alto rendimiento. Veamos un ejemplo para un catálogo de productos de una tienda en línea.
Creamos una clase para gestionar el índice:
<?php
namespace App\Search\Indices;
use Elastic\Elasticsearch\Client;
class ProductIndex
{
public function __construct(private Client $client) {}
public function create(): void
{
$this->client->indices()->create([
'index' => 'products',
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 1,
'analysis' => [
'analyzer' => [
'spanish_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => [
'lowercase',
'spanish_stop',
'spanish_stemmer',
],
],
],
'filter' => [
'spanish_stop' => [
'type' => 'stop',
'stopwords' => '_spanish_',
],
'spanish_stemmer' => [
'type' => 'stemmer',
'language' => 'spanish',
],
],
],
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'name' => [
'type' => 'text',
'analyzer' => 'spanish_analyzer',
'fields' => [
'keyword' => ['type' => 'keyword'],
],
],
'description' => [
'type' => 'text',
'analyzer' => 'spanish_analyzer',
],
'price' => ['type' => 'float'],
'category_id' => ['type' => 'integer'],
'brand' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'in_stock' => ['type' => 'boolean'],
'rating' => ['type' => 'float'],
'created_at' => ['type' => 'date'],
],
],
],
]);
}
public function delete(): void
{
if ($this->client->indices()->exists(['index' => 'products'])->asBool()) {
$this->client->indices()->delete(['index' => 'products']);
}
}
}
Decisiones importantes en el mapeo:
- text + subcampo keyword para el campo
name:textse usa para búsqueda de texto completo,keywordpara filtrado exacto y ordenación. - Stemmer y stopwords en español: sin ellos, buscar «portátil» no encontraría «portátiles».
- keyword para
brandytags: estos campos se usan solo para filtrado facetado y agregaciones, el análisis de texto completo no es necesario.
Implementación de búsqueda de texto completo, filtrado facetado y ordenación
Creamos la clase ProductSearchService, que recibe los parámetros de la consulta y construye el cuerpo de la petición para Elasticsearch:
<?php
namespace App\Search;
use Elastic\Elasticsearch\Client;
use Illuminate\Support\Collection;
class ProductSearchService
{
public function __construct(private Client $client) {}
public function search(
string $query = '',
array $filters = [],
string $sortBy = 'relevance',
int $page = 1,
int $perPage = 20
): array {
$from = ($page - 1) * $perPage;
$body = [
'from' => $from,
'size' => $perPage,
'query' => $this->buildQuery($query, $filters),
'sort' => $this->buildSort($sortBy, $query),
'aggs' => $this->buildAggregations(),
];
$response = $this->client->search([
'index' => 'products',
'body' => $body,
]);
return $this->formatResponse($response->asArray(), $perPage);
}
private function buildQuery(string $query, array $filters): array
{
$must = [];
$filter = [];
if (!empty($query)) {
$must[] = [
'multi_match' => [
'query' => $query,
'fields' => ['name^3', 'description^1', 'brand^2'],
'type' => 'best_fields',
'fuzziness' => 'AUTO',
],
];
}
if (!empty($filters['category_id'])) {
$filter[] = ['term' => ['category_id' => $filters['category_id']]];
}
if (!empty($filters['brand'])) {
$filter[] = ['terms' => ['brand' => (array)$filters['brand']]];
}
if (isset($filters['price_min']) || isset($filters['price_max'])) {
$range = [];
if (isset($filters['price_min'])) $range['gte'] = $filters['price_min'];
if (isset($filters['price_max'])) $range['lte'] = $filters['price_max'];
$filter[] = ['range' => ['price' => $range]];
}
if (isset($filters['in_stock']) && $filters['in_stock']) {
$filter[] = ['term' => ['in_stock' => true]];
}
if (empty($must) && empty($filter)) {
return ['match_all' => (object)[]];
}
return [
'bool' => [
'must' => $must,
'filter' => $filter,
],
];
}
private function buildSort(string $sortBy, string $query): array
{
return match ($sortBy) {
'price_asc' => [['price' => 'asc']],
'price_desc' => [['price' => 'desc']],
'rating' => [['rating' => 'desc']],
'newest' => [['created_at' => 'desc']],
default => empty($query)
? [['rating' => 'desc']]
: ['_score'],
};
}
private function buildAggregations(): array
{
return [
'brands' => [
'terms' => ['field' => 'brand', 'size' => 50],
],
'price_range' => [
'stats' => ['field' => 'price'],
],
'in_stock_count' => [
'filter' => ['term' => ['in_stock' => true]],
],
];
}
private function formatResponse(array $response, int $perPage): array
{
$hits = $response['hits'];
$ids = array_column(array_column($hits['hits'], '_source'), 'id');
return [
'total' => $hits['total']['value'],
'ids' => $ids,
'aggregations' => $response['aggregations'] ?? [],
'pages' => (int) ceil($hits['total']['value'] / $perPage),
];
}
}
Patrón de separación de responsabilidades: ProductSearchService devuelve la lista de IDs y las agregaciones. El controlador luego carga los modelos completos desde MySQL usando esos IDs, manteniendo el orden:
$result = $searchService->search($request->q, $request->filters);
$products = Product::whereIn('id', $result['ids'])
->get()
->sortBy(fn($p) => array_search($p->id, $result['ids']))
->values();
Sincronización de datos entre MySQL y Elasticsearch
Utilizamos el patrón Observer y Laravel Queue para la sincronización asíncrona.
Creamos un Job para indexar un documento:
<?php
namespace App\Jobs;
use App\Models\Product;
use Elastic\Elasticsearch\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class IndexProductJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 3;
public int $backoff = 5;
public function __construct(private int $productId) {}
public function handle(Client $client): void
{
$product = Product::with(['category', 'tags'])->find($this->productId);
if (!$product) {
$client->delete([
'index' => 'products',
'id' => $this->productId,
]);
return;
}
$client->index([
'index' => 'products',
'id' => $product->id,
'body' => [
'id' => $product->id,
'name' => $product->name,
'description' => $product->description,
'price' => (float) $product->price,
'category_id' => $product->category_id,
'brand' => $product->brand,
'tags' => $product->tags->pluck('name')->toArray(),
'in_stock' => $product->quantity > 0,
'rating' => (float) $product->rating,
'created_at' => $product->created_at->toISOString(),
],
]);
}
}
Creamos un Job para eliminar un documento del índice:
<?php
namespace App\Jobs;
use Elastic\Elasticsearch\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class DeleteProductFromIndexJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(private int $productId) {}
public function handle(Client $client): void
{
$client->delete([
'index' => 'products',
'id' => $this->productId,
'ignore' => [404],
]);
}
}
El Observer que lanza las tareas:
<?php
namespace App\Observers;
use App\Jobs\DeleteProductFromIndexJob;
use App\Jobs\IndexProductJob;
use App\Models\Product;
class ProductObserver
{
public function created(Product $product): void
{
IndexProductJob::dispatch($product->id)->onQueue('search');
}
public function updated(Product $product): void
{
IndexProductJob::dispatch($product->id)->onQueue('search');
}
public function deleted(Product $product): void
{
DeleteProductFromIndexJob::dispatch($product->id)->onQueue('search');
}
}
Registramos el Observer en AppServiceProvider:
Product::observe(ProductObserver::class);
Para la importación inicial de datos creamos un comando Artisan:
<?php
namespace App\Console\Commands;
use App\Jobs\IndexProductJob;
use App\Models\Product;
use Illuminate\Console\Command;
class ReindexProductsCommand extends Command
{
protected $signature = 'search:reindex-products';
protected $description = 'Reindex all products in Elasticsearch';
public function handle(): void
{
$total = Product::count();
$this->info("Encolando {$total} productos para reindexar...");
$bar = $this->output->createProgressBar($total);
Product::query()->select('id')->chunkById(500, function ($products) use ($bar) {
foreach ($products as $product) {
IndexProductJob::dispatch($product->id)->onQueue('search');
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$this->info('Listo. Los workers procesarán la cola.');
}
}
Caché de consultas de búsqueda con Redis
No todas las consultas de búsqueda necesitan enviarse a Elasticsearch. Las consultas populares pueden cachearse en Redis, reduciendo significativamente la carga.
Creamos un decorador para ProductSearchService:
<?php
namespace App\Search;
use Illuminate\Support\Facades\Cache;
class CachedProductSearchService
{
private const TTL = 300; // 5 minutos
public function __construct(
private ProductSearchService $searchService
) {}
public function search(
string $query = '',
array $filters = [],
string $sortBy = 'relevance',
int $page = 1,
int $perPage = 20
): array {
$cacheKey = $this->buildCacheKey($query, $filters, $sortBy, $page, $perPage);
return Cache::store('redis')->remember(
$cacheKey,
self::TTL,
fn() => $this->searchService->search(
$query, $filters, $sortBy, $page, $perPage
)
);
}
private function buildCacheKey(
string $query,
array $filters,
string $sortBy,
int $page,
int $perPage
): string {
$data = compact('query', 'filters', 'sortBy', 'page', 'perPage');
return 'search:products:' . md5(serialize($data));
}
public function invalidate(): void
{
// Caché etiquetado para invalidación masiva
Cache::store('redis')->tags(['search:products'])->flush();
}
}
Importante: al actualizar los datos de un producto, invalida la caché mediante etiquetas o por patrón de clave. De lo contrario, los usuarios verán resultados desactualizados tras una edición. Redis con soporte de etiquetas en Laravel es la opción ideal para esta tarea.
Pruebas de la capa de búsqueda
Las pruebas de Elasticsearch en Laravel se estructuran en varios niveles.
Pruebas unitarias para los constructores de consultas
Mockeamos el cliente y verificamos la estructura de la consulta generada:
<?php
namespace Tests\Unit\Search;
use App\Search\ProductSearchService;
use Elastic\Elasticsearch\Client;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class ProductSearchServiceTest extends TestCase
{
private Client&MockObject $client;
private ProductSearchService $service;
protected function setUp(): void
{
parent::setUp();
$this->client = $this->createMock(Client::class);
$this->service = new ProductSearchService($this->client);
}
public function test_search_sends_correct_query(): void
{
$response = $this->createMock(\Elastic\Elasticsearch\Response\Elasticsearch::class);
$response->method('asArray')->willReturn([
'hits' => [
'total' => ['value' => 1],
'hits' => [['_source' => ['id' => 1]]],
],
'aggregations' => [],
]);
$this->client->expects($this->once())
->method('search')
->with($this->callback(function ($params) {
$query = $params['body']['query'];
return isset($query['bool']['must'][0]['multi_match'])
&& $query['bool']['must'][0]['multi_match']['query'] === 'portátil';
}))
->willReturn($response);
$result = $this->service->search('portátil');
$this->assertEquals(1, $result['total']);
$this->assertEquals([1], $result['ids']);
}
}
Pruebas de integración
Para las pruebas de integración levantamos un Elasticsearch real en Docker y ejecutamos las pruebas con índices reales. En phpunit.xml definimos la variable de entorno:
<env name="ELASTICSEARCH_HOST" value="http://localhost:9201"/>
En la clase base de pruebas creamos el índice antes de las pruebas y lo eliminamos después:
protected function setUp(): void
{
parent::setUp();
(new ProductIndex(app('elasticsearch')))->create();
}
protected function tearDown(): void
{
(new ProductIndex(app('elasticsearch')))->delete();
parent::tearDown();
}
Despliegue de Elasticsearch en Docker junto a la aplicación Laravel
El enfoque moderno consiste en ejecutar Elasticsearch como servicio en docker-compose.yml junto a Laravel:
services:
app:
build: .
environment:
- ELASTICSEARCH_HOST=http://elasticsearch:9200
- ELASTICSEARCH_USER=elastic
- ELASTICSEARCH_PASSWORD=secret
depends_on:
elasticsearch:
condition: service_healthy
queue-worker:
build: .
command: php artisan queue:work redis --queue=search --tries=3
depends_on:
- app
- redis
- elasticsearch
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
environment:
- discovery.type=single-node
- ELASTIC_PASSWORD=secret
- xpack.security.enabled=true
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- es_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -s -u elastic:secret http://localhost:9200/_cluster/health | grep -v red"]
interval: 20s
timeout: 10s
retries: 5
volumes:
es_data:
redis_data:
Algunas recomendaciones para producción:
- Configura
ES_JAVA_OPTScon un máximo del 50% de la memoria disponible del servidor, pero no más de 32 GB (límite de JVM Compressed OOPs). - Usa un volumen separado para los datos de Elasticsearch: esto acelera la recuperación tras el reinicio del contenedor.
- En producción despliega un mínimo de 3 nodos para alta disponibilidad.
- Para los workers de Laravel en la cola
search, utiliza Supervisor o Laravel Horizon. - Monitorea el estado de los índices con la API integrada
_cat/healtho con Elastic APM.
Conclusión
La integración de Elasticsearch con Laravel en 2026 es una práctica madura y bien documentada. Los principios clave que hemos visto: MySQL sigue siendo la fuente de verdad, Elasticsearch almacena proyecciones desnormalizadas para la búsqueda, la sincronización se realiza mediante colas y el patrón Observer, Redis cachea las consultas de búsqueda más frecuentes y Docker aísla el entorno.
Siguiendo esta arquitectura, obtienes una capa de búsqueda de alto rendimiento, escalable y testeable, que no sobrecarga la base de datos principal y al mismo tiempo ofrece a los usuarios una búsqueda instantánea y relevante con filtrado facetado.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →