Elasticsearch и PHP: построение производительного поискового слоя для Laravel-приложений с нуля в 2026 году
Введение: когда Elasticsearch нужен рядом с MySQL
MySQL и PostgreSQL отлично справляются с реляционными запросами, транзакциями и хранением структурированных данных. Но как только пользователь начинает вводить запрос в поисковую строку — ситуация меняется. LIKE '%query%' не масштабируется, FULLTEXT-индексы в MySQL дают слабый релевантность и не поддерживают синонимы, морфологию или фасетную фильтрацию без серьёзных ухищрений.
Elasticsearch — распределённый поисковый движок на базе Apache Lucene — закрывает именно этот пробел. В 2026 году он остаётся стандартом де-факто для построения поискового слоя в высоконагруженных Laravel-приложениях: маркетплейсах, новостных агрегаторах, SaaS-платформах с каталогами товаров.
В этой статье мы пройдём путь от нуля: настроим клиент, спроектируем индексы, реализуем поиск с фильтрацией и сортировкой, выстроим синхронизацию данных через очереди и кэшируем запросы через Redis.
Архитектура интеграции: синхронизация, индексирование и поиск
Ключевое правило: Elasticsearch — это вторичное хранилище. Источником истины остаётся MySQL или PostgreSQL. Elasticsearch хранит денормализованные «проекции» документов, оптимизированные для поиска.
Типичный поток данных выглядит так:
- Пользователь создаёт или обновляет запись через Laravel-приложение.
- Eloquent-событие (
created,updated,deleted) запускает Observer. - Observer отправляет задачу в очередь (Redis + Laravel Queue).
- Worker выполняет задачу: формирует документ и индексирует его в Elasticsearch.
- При поиске приложение обращается напрямую к Elasticsearch, получает ID документов и при необходимости загружает полные записи из БД.
Такой подход защищает от замедления основных запросов и изолирует поисковый слой от бизнес-логики.
Настройка Elasticsearch и клиента в Laravel
Устанавливаем официальный PHP-клиент Elasticsearch 8.x:
composer require elastic/elasticsearch
Создаём сервис-провайдер и регистрируем клиент в контейнере:
<?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();
});
}
}
Добавляем конфигурацию в config/services.php:
'elasticsearch' => [
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
'user' => env('ELASTICSEARCH_USER', 'elastic'),
'password' => env('ELASTICSEARCH_PASSWORD', 'secret'),
],
Регистрируем провайдер в bootstrap/providers.php (Laravel 11+) или в config/app.php.
Проектирование индексов: маппинг, анализаторы, поля
Правильный маппинг — фундамент производительного поиска. Рассмотрим пример для каталога товаров интернет-магазина.
Создаём класс для управления индексом:
<?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' => [
'russian_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => [
'lowercase',
'russian_stop',
'russian_stemmer',
],
],
],
'filter' => [
'russian_stop' => [
'type' => 'stop',
'stopwords' => '_russian_',
],
'russian_stemmer' => [
'type' => 'stemmer',
'language' => 'russian',
],
],
],
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'name' => [
'type' => 'text',
'analyzer' => 'russian_analyzer',
'fields' => [
'keyword' => ['type' => 'keyword'],
],
],
'description' => [
'type' => 'text',
'analyzer' => 'russian_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']);
}
}
}
Важные решения в маппинге:
- text + keyword sub-field для поля
name:textиспользуется для полнотекстового поиска,keyword— для точной фильтрации и сортировки. - Русский стеммер и стоп-слова: без них поиск «ноутбук» не найдёт «ноутбуки».
- keyword для
brandиtags: эти поля используются только для фасетной фильтрации и агрегаций, полнотекстовый анализ не нужен.
Реализация полнотекстового поиска, фасетной фильтрации и сортировки
Создаём класс ProductSearchService, который принимает параметры запроса и строит тело запроса для 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),
];
}
}
Паттерн разделения ответственности: ProductSearchService возвращает список ID и агрегации. Контроллер затем загружает полные модели из MySQL по этим ID, сохраняя порядок:
$result = $searchService->search($request->q, $request->filters);
$products = Product::whereIn('id', $result['ids'])
->get()
->sortBy(fn($p) => array_search($p->id, $result['ids']))
->values();
Синхронизация данных между MySQL и Elasticsearch
Используем Observer-паттерн и Laravel Queue для асинхронной синхронизации.
Создаём Job для индексирования документа:
<?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(),
],
]);
}
}
Создаём Job для удаления документа из индекса:
<?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],
]);
}
}
Observer, который запускает задачи:
<?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');
}
}
Регистрируем Observer в AppServiceProvider:
Product::observe(ProductObserver::class);
Для первоначального импорта данных создаём 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("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.');
}
}
Кэширование поисковых запросов с Redis
Не каждый поисковый запрос нужно отправлять в Elasticsearch. Популярные запросы можно кэшировать в Redis, значительно снижая нагрузку.
Создаём декоратор для ProductSearchService:
<?php
namespace App\Search;
use Illuminate\Support\Facades\Cache;
class CachedProductSearchService
{
private const TTL = 300; // 5 минут
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
{
// Тегированный кэш для массовой инвалидации
Cache::store('redis')->tags(['search:products'])->flush();
}
}
Важно: при обновлении данных товара инвалидируйте кэш через теги или по шаблону ключа. Иначе пользователи будут видеть устаревшие результаты после редактирования. Redis с поддержкой тегов в Laravel — идеальный выбор для этой задачи.
Тестирование поискового слоя
Тестирование Elasticsearch в Laravel строится на нескольких уровнях.
Юнит-тесты для построителей запросов
Мокируем клиент и проверяем структуру сформированного запроса:
<?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'] === 'ноутбук';
}))
->willReturn($response);
$result = $this->service->search('ноутбук');
$this->assertEquals(1, $result['total']);
$this->assertEquals([1], $result['ids']);
}
}
Интеграционные тесты
Для интеграционных тестов поднимаем реальный Elasticsearch в Docker и запускаем тесты с реальными индексами. В phpunit.xml задаём переменную окружения:
<env name="ELASTICSEARCH_HOST" value="http://localhost:9201"/>
В тестовом базовом классе создаём индекс перед тестами и удаляем после:
protected function setUp(): void
{
parent::setUp();
(new ProductIndex(app('elasticsearch')))->create();
}
protected function tearDown(): void
{
(new ProductIndex(app('elasticsearch')))->delete();
parent::tearDown();
}
Деплой Elasticsearch в Docker рядом с Laravel-приложением
Современный подход — запускать Elasticsearch как сервис в docker-compose.yml рядом с 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:
Несколько рекомендаций для продакшена:
- Устанавливайте
ES_JAVA_OPTSв пределах 50% доступной памяти сервера, но не более 32 GB (ограничение JVM Compressed OOPs). - Используйте отдельный volume для данных Elasticsearch — это ускоряет восстановление после перезапуска контейнера.
- В продакшене разверните минимум 3 ноды для отказоустойчивости.
- Для Laravel workers на очереди
searchиспользуйте Supervisor или Laravel Horizon. - Мониторинг состояния индексов — через встроенный
_cat/healthAPI или Elastic APM.
Заключение
Интеграция Elasticsearch с Laravel в 2026 году — это зрелая и хорошо документированная практика. Главные принципы, которые мы рассмотрели: MySQL остаётся источником истины, Elasticsearch хранит денормализованные проекции для поиска, синхронизация идёт через очереди и Observer-паттерн, Redis кэширует горячие поисковые запросы, а Docker изолирует окружение.
Следуя этой архитектуре, вы получаете производительный, масштабируемый и тестируемый поисковый слой, который не перегружает основную базу данных и при этом даёт пользователям мгновенный релевантный поиск с фасетной фильтрацией.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →