Elasticsearch and PHP: Building a High-Performance Search Layer for Laravel Applications from Scratch in 2026
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:
- A user creates or updates a record through the Laravel application.
- An Eloquent event (
created,updated,deleted) triggers an Observer. - The Observer dispatches a job to the queue (Redis + Laravel Queue).
- A worker processes the job: builds the document and indexes it in Elasticsearch.
- 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
namefield:textis used for full-text search,keywordfor exact filtering and sorting. - Stemmer and stop words: without them, searching "laptop" may not match "laptops".
- keyword for
brandandtags: 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_OPTSto 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
searchqueue, use Supervisor or Laravel Horizon. - Monitor index health via the built-in
_cat/healthAPI 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 →