Desarrollo backend

Elasticsearch y PHP: construyendo una capa de búsqueda de alto rendimiento para aplicaciones Laravel desde cero en 2026

Ruslan Ismailov Publicado 18 min de lectura
E

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:

  1. El usuario crea o actualiza un registro a través de la aplicación Laravel.
  2. Un evento de Eloquent (created, updated, deleted) dispara un Observer.
  3. El Observer envía una tarea a la cola (Redis + Laravel Queue).
  4. El worker ejecuta la tarea: forma el documento y lo indexa en Elasticsearch.
  5. 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: text se usa para búsqueda de texto completo, keyword para filtrado exacto y ordenación.
  • Stemmer y stopwords en español: sin ellos, buscar «portátil» no encontraría «portátiles».
  • keyword para brand y tags: 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_OPTS con 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/health o 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í →