Backend development

Elasticsearch and PHP: Building a High-Performance Search Layer for Laravel Applications from Scratch in 2026

Ruslan Ismailov Published 18 min read
E

Introduction: When Elasticsearch Belongs Next to MySQL

MySQL and PostgreSQL handle relational queries, transactions, and structured data storage exceptionally well. But the moment a user starts typing into a search box, the situation changes. LIKE '%query%' doesn't scale, and MySQL's FULLTEXT indexes deliver poor relevance with no support for synonyms, morphology, or faceted filtering without serious workarounds.

Elasticsearch — a distributed search engine built on Apache Lucene — fills exactly this gap. In 2026, it remains the de facto standard for building a search layer in high-load Laravel applications: marketplaces, news aggregators, and SaaS platforms with product catalogs.

In this article, we'll go from zero: set up the client, design indexes, implement search with filtering and sorting, establish data synchronization via queues, and cache queries with Redis.

Integration Architecture: Synchronization, Indexing, and Search

The key rule: Elasticsearch is a secondary store. MySQL or PostgreSQL remains the source of truth. Elasticsearch stores denormalized "projections" of documents, optimized for search.

A typical data flow looks like this:

  1. A user creates or updates a record through the Laravel application.
  2. An Eloquent event (created, updated, deleted) triggers an Observer.
  3. The Observer dispatches a job to the queue (Redis + Laravel Queue).
  4. A worker processes the job: builds the document and indexes it in Elasticsearch.
  5. When searching, the application queries Elasticsearch directly, retrieves document IDs, and loads full records from the database as needed.

This approach prevents slowdowns in core queries and isolates the search layer from business logic.

Setting Up Elasticsearch and the Client in Laravel

Install the official Elasticsearch 8.x PHP client:

composer require elastic/elasticsearch

Create a service provider and register the client in the container:

<?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();
        });
    }
}

Add the configuration to config/services.php:

'elasticsearch' => [
    'host'     => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
    'user'     => env('ELASTICSEARCH_USER', 'elastic'),
    'password' => env('ELASTICSEARCH_PASSWORD', 'secret'),
],

Register the provider in bootstrap/providers.php (Laravel 11+) or in config/app.php.

Designing Indexes: Mapping, Analyzers, and Fields

Proper mapping is the foundation of performant search. Let's look at an example for an e-commerce product catalog.

Create a class to manage the index:

<?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' => [
                            'english_analyzer' => [
                                'type'      => 'custom',
                                'tokenizer' => 'standard',
                                'filter'    => [
                                    'lowercase',
                                    'english_stop',
                                    'english_stemmer',
                                ],
                            ],
                        ],
                        'filter' => [
                            'english_stop' => [
                                'type'      => 'stop',
                                'stopwords' => '_english_',
                            ],
                            'english_stemmer' => [
                                'type'     => 'stemmer',
                                'language' => 'english',
                            ],
                        ],
                    ],
                ],
                'mappings' => [
                    'properties' => [
                        'id'          => ['type' => 'integer'],
                        'name'        => [
                            'type'     => 'text',
                            'analyzer' => 'english_analyzer',
                            'fields'   => [
                                'keyword' => ['type' => 'keyword'],
                            ],
                        ],
                        'description' => [
                            'type'     => 'text',
                            'analyzer' => 'english_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']);
        }
    }
}

Key mapping decisions:

  • text + keyword sub-field for the name field: text is used for full-text search, keyword for exact filtering and sorting.
  • Stemmer and stop words: without them, searching "laptop" may not match "laptops".
  • keyword for brand and tags: these fields are used only for faceted filtering and aggregations — full-text analysis is not needed.

Implementing Full-Text Search, Faceted Filtering, and Sorting

Create a ProductSearchService class that accepts request parameters and builds the query body for 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),
        ];
    }
}

Separation of concerns pattern: ProductSearchService returns a list of IDs and aggregations. The controller then loads the full models from MySQL by these IDs, preserving order:

$result = $searchService->search($request->q, $request->filters);
$products = Product::whereIn('id', $result['ids'])
    ->get()
    ->sortBy(fn($p) => array_search($p->id, $result['ids']))
    ->values();

Data Synchronization Between MySQL and Elasticsearch

We use the Observer pattern and Laravel Queue for asynchronous synchronization.

Create a Job for indexing a document:

<?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(),
            ],
        ]);
    }
}

Create a Job for removing a document from the index:

<?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],
        ]);
    }
}

The Observer that dispatches the jobs:

<?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');
    }
}

Register the Observer in AppServiceProvider:

Product::observe(ProductObserver::class);

For the initial data import, create an Artisan command:

<?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("Queuing {$total} products for reindex...");

        $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('Done. Workers will process the queue.');
    }
}

Caching Search Queries with Redis

Not every search query needs to hit Elasticsearch. Popular queries can be cached in Redis, significantly reducing load.

Create a decorator for ProductSearchService:

<?php

namespace App\Search;

use Illuminate\Support\Facades\Cache;

class CachedProductSearchService
{
    private const TTL = 300; // 5 minutes

    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
    {
        // Tagged cache for bulk invalidation
        Cache::store('redis')->tags(['search:products'])->flush();
    }
}

Important: when product data is updated, invalidate the cache using tags or a key pattern. Otherwise, users will see stale results after an edit. Redis with tag support in Laravel is the ideal choice for this task.

Testing the Search Layer

Testing Elasticsearch in Laravel operates on several levels.

Unit Tests for Query Builders

Mock the client and verify the structure of the generated query:

<?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'] === 'laptop';
            }))
            ->willReturn($response);

        $result = $this->service->search('laptop');

        $this->assertEquals(1, $result['total']);
        $this->assertEquals([1], $result['ids']);
    }
}

Integration Tests

For integration tests, spin up a real Elasticsearch instance in Docker and run tests against real indexes. In phpunit.xml, set the environment variable:

<env name="ELASTICSEARCH_HOST" value="http://localhost:9201"/>

In the test base class, create the index before tests and delete it after:

protected function setUp(): void
{
    parent::setUp();
    (new ProductIndex(app('elasticsearch')))->create();
}

protected function tearDown(): void
{
    (new ProductIndex(app('elasticsearch')))->delete();
    parent::tearDown();
}

Deploying Elasticsearch in Docker Alongside a Laravel Application

The modern approach is to run Elasticsearch as a service in docker-compose.yml alongside 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:

A few recommendations for production:

  • Set ES_JAVA_OPTS to no more than 50% of available server memory, and no more than 32 GB (JVM Compressed OOPs limit).
  • Use a dedicated volume for Elasticsearch data — this speeds up recovery after a container restart.
  • In production, deploy a minimum of 3 nodes for high availability.
  • For Laravel workers on the search queue, use Supervisor or Laravel Horizon.
  • Monitor index health via the built-in _cat/health API or Elastic APM.

Conclusion

Integrating Elasticsearch with Laravel in 2026 is a mature and well-documented practice. The key principles we covered: MySQL remains the source of truth, Elasticsearch stores denormalized projections for search, synchronization runs through queues and the Observer pattern, Redis caches hot search queries, and Docker isolates the environment.

By following this architecture, you get a performant, scalable, and testable search layer that doesn't overload your primary database while delivering instant, relevant search results with faceted filtering to your users.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →