Backend-разработка

Elasticsearch и PHP: построение производительного поискового слоя для Laravel-приложений с нуля в 2026 году

Ruslan Ismailov Опубликовано 18 мин чтения
E

Введение: когда Elasticsearch нужен рядом с MySQL

MySQL и PostgreSQL отлично справляются с реляционными запросами, транзакциями и хранением структурированных данных. Но как только пользователь начинает вводить запрос в поисковую строку — ситуация меняется. LIKE '%query%' не масштабируется, FULLTEXT-индексы в MySQL дают слабый релевантность и не поддерживают синонимы, морфологию или фасетную фильтрацию без серьёзных ухищрений.

Elasticsearch — распределённый поисковый движок на базе Apache Lucene — закрывает именно этот пробел. В 2026 году он остаётся стандартом де-факто для построения поискового слоя в высоконагруженных Laravel-приложениях: маркетплейсах, новостных агрегаторах, SaaS-платформах с каталогами товаров.

В этой статье мы пройдём путь от нуля: настроим клиент, спроектируем индексы, реализуем поиск с фильтрацией и сортировкой, выстроим синхронизацию данных через очереди и кэшируем запросы через Redis.

Архитектура интеграции: синхронизация, индексирование и поиск

Ключевое правило: Elasticsearch — это вторичное хранилище. Источником истины остаётся MySQL или PostgreSQL. Elasticsearch хранит денормализованные «проекции» документов, оптимизированные для поиска.

Типичный поток данных выглядит так:

  1. Пользователь создаёт или обновляет запись через Laravel-приложение.
  2. Eloquent-событие (created, updated, deleted) запускает Observer.
  3. Observer отправляет задачу в очередь (Redis + Laravel Queue).
  4. Worker выполняет задачу: формирует документ и индексирует его в Elasticsearch.
  5. При поиске приложение обращается напрямую к 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/health API или 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. Подробнее обо мне →