🔎 Elasticsearch z Laravel: Kompletny przewodnik

Ten przewodnik pokazuje, jak zintegrować Elasticsearch z Laravel.

📋 Spis treści


1. Dlaczego Elasticsearch?

Eloquent Laravel jest świetny dla większości zapytań, ale gdy potrzebujesz wyszukiwania pełnotekstowego, agregacji lub wyszukiwania geograficznego, Elasticsearch jest najlepszym rozwiązaniem. Czysta integracja z Laravel zapewnia, że Twój kod pozostanie łatwy w utrzymaniu i testowaniu.


2. Instalacja i konfiguracja

Najpierw zainstaluj oficjalnego klienta PHP dla Elasticsearch:

composer require elasticsearch/elasticsearch

Następnie dodaj Elasticsearch do pliku docker-compose.yml:

services:
    ...
    elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
    container_name: laravel_elasticsearch
    environment:
        - discovery.type=single-node
        - ES_JAVA_OPTS=-Xms1g -Xmx1g
        - xpack.security.enabled=false
        - xpack.monitoring.collection.enabled=true
    ports:
        - "9200:9200"
    volumes:
        - esdata:/usr/share/elasticsearch/data
    networks:
        - laravel-network

voluems:
    ...
    esdata:
      driver: local

Następnie utwórz plik konfiguracyjny dla Elasticsearch:

touch config/elasticsearch.php
<?php

return [
    'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
];

3. Warstwa serwisu: Opakowanie klienta Elasticsearch

Aby zachować czysty i testowalny kod, zamknij całą logikę Elasticsearch w dedykowanym serwisie.

app/Services/ElasticsearchService.php

<?php

namespace App\Services;

use Elastic\Elasticsearch\ClientBuilder;

class ElasticsearchService
{
    public function client()
    {
        return ClientBuilder::create()
            ->setHosts([config('elasticsearch.host')])
            ->build();
    }

    public function createArticlesIndex(): void
    {
        $params = [
            'index' => 'articles',
            'body' => [
                'settings' => [
                    'analysis' => [
                        'analyzer' => [
                            'autocomplete' => [
                                'tokenizer' => 'autocomplete',
                                'filter' => ['lowercase'],
                            ],
                        ],
                        'tokenizer' => [
                            'autocomplete' => [
                                'type' => 'edge_ngram',
                                'min_gram' => 1,
                                'max_gram' => 20,
                                'token_chars' => ['letter', 'digit'],
                            ],
                        ],
                    ],
                ],
                'mappings' => [
                    'properties' => [
                        'title' => [
                            'type' => 'text',
                            'analyzer' => 'autocomplete',
                            'search_analyzer' => 'standard',
                        ],
                        'tags' => [
                            'type' => 'keyword',
                        ],
                        'user' => [
                            'type' => 'keyword',
                        ],
                        'location' => [
                            'type' => 'geo_point',
                        ],
                        'city_name' => [
                            'type' => 'keyword',
                        ],
                    ],
                ],
            ],
        ];

        if ($this->client()->indices()->exists(['index' => 'articles'])->asBool()) {
            $this->client()->indices()->delete(['index' => 'articles']);
        }

        $this->client()->indices()->create($params);
    }

    public function index(array $params)
    {
        return $this->client()->index($params);
    }
}

4. Obiekt transferu danych (DTO) dla filtrów wyszukiwania

Użycie DTO (z spatie/laravel-data) utrzymuje logikę wyszukiwania w czystości i zapewnia bezpieczeństwo typów.

app/DTO/ArticleFilterData.php

<?php

namespace App\DTO;

use Spatie\LaravelData\Data;

class ArticleFilterData extends Data
{
    public function __construct(
        public ?string $q = '',
        public ?string $tag = null,
        public ?string $city = null,
        public ?int $radius = null,
        public ?float $lat = null,
        public ?float $lon = null,
        public ?int $page = 1,
        public ?int $size = 20,
    ) {}
}

5. Serwis wyszukiwania: Logika biznesowa dla wyszukiwania artykułów

Ten serwis buduje zapytanie Elasticsearch na podstawie DTO i zwraca ustrukturyzowane wyniki.

app/Services/ArticleSearchService.php

<?php

namespace App\Services;

use App\DTO\ArticleFilterData;
use Illuminate\Support\Arr;

readonly class ArticleSearchService
{
    public function __construct(private ElasticsearchService $es) {}

    public function search(ArticleFilterData $filters): array
    {
        $queryBody = [
            'index' => 'articles',
            'from' => ($filters->page - 1) * $filters->size,
            'size' => $filters->size,
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => $this->buildMustQueries($filters),
                        'filter' => $this->buildFilterQueries($filters),
                    ],
                ],
            ],
        ];

        $results = $this->es->client()->search($queryBody);

        return [
            'articles' => collect($results['hits']['hits'])->pluck('_source')->all(),
            'total' => $results['hits']['total']['value'] ?? 0,
            'filters' => $filters->toArray(),
        ];
    }

    private function buildMustQueries(ArticleFilterData $filters): array
    {
        $must = [];

        if ($filters->q) {
            $must[] = mb_strlen($filters->q) <= 2
                ? ['wildcard' => ['title' => "*{$filters->q}*"]]
                : [
                    'multi_match' => [
                        'query' => $filters->q,
                        'fields' => ['title^2', 'tags'],
                        'fuzziness' => 'auto',
                        'operator' => 'and',
                        'minimum_should_match' => '100%',
                    ],
                ];
        }

        if ($filters->tag) {
            $must[] = ['term' => ['tags' => $filters->tag]];
        }

        return $must;
    }

    private function buildFilterQueries(ArticleFilterData $filters): array
    {
        $filter = [];

        if ($filters->city) {
            $filter[] = ['term' => ['city_name' => $filters->city]];
        }

        if ($filters->lat && $filters->lon && $filters->radius > 0) {
            $filter = array_filter($filter, fn($f) => !Arr::has($f, 'term.city_name'));
            $filter[] = [
                'geo_distance' => [
                    'distance' => "{$filters->radius}km",
                    'location' => [
                        'lat' => $filters->lat,
                        'lon' => $filters->lon,
                    ],
                ],
            ];
        }

        return $filter;
    }
}

6. Komenda do tworzenia indeksu

app/Console/Commands/ReindexArticlesElasticsearch.php

<?php

namespace App\Console\Commands;

use App\Jobs\ReindexArticles;
use App\Services\ElasticsearchService;
use Elastic\Elasticsearch\Exception\AuthenticationException;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\MissingParameterException;
use Elastic\Elasticsearch\Exception\ServerResponseException;
use Illuminate\Console\Command;

class ReindexArticlesElasticsearch extends Command
{
    protected $signature = 'es:reindex-articles';

    protected $description = 'Tworzy indeks artykułów z mapowaniem i reindeksuje artykuły do Elasticsearch';

    /**
     * @throws AuthenticationException
     * @throws ClientResponseException
     * @throws ServerResponseException
     * @throws MissingParameterException
     */
    public function handle(ElasticsearchService $es): void
    {
        $this->info('Tworzenie indeksu articles z mapowaniem...');
        $es->createArticlesIndex();
        $this->info('Reindeksowanie artykułów...');
        ReindexArticles::dispatchSync();
        $this->info('Gotowe!');
    }
}

7. Zadanie do reindeksacji artykułów

app/Jobs/ReindexArticles.php

<?php

namespace App\Jobs;

use App\Models\Article;
use App\Models\User;
use App\Services\ElasticsearchService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ReindexArticles implements ShouldQueue
{
    use Queueable;

    public function handle(ElasticsearchService $es): void
    {
        Article::with(['user', 'tags'])->chunk(100, function ($articles) use ($es) {
            foreach ($articles as $article) {

                /** @var User $user */
                $user = $article->user;

                $es->index([
                    'index' => 'articles',
                    'id' => $article->id,
                    'body' => [
                        'title' => $article->title,
                        'slug' => $article->slug,
                        'content' => $article->content,
                        'user' => $user->name,
                        'tags' => $article->tags->pluck('name')->toArray(),
                        'location' => $article->location ?? null,
                        'city_name' => $article->city_name ?? null,
                    ],
                ]);
            }
        });
    }
}

8. Obserwator dla modelu Article

app/Observers/ArticleObserver.php

<?php

namespace App\Observers;

use App\Jobs\ReindexArticles;
use App\Models\Article;
use App\Services\ElasticsearchService;

class ArticleObserver
{
    public function deleted(Article $article): void
    {
        $es = app(ElasticsearchService::class)->client();

        try {
            $es->delete([
                'index' => 'articles',
                'id' => $article->id,
            ]);
        } catch (\Exception $e) {
            \Log::info($e->getMessage());
        }
    }

    public function saved(Article $article)
    {
        ReindexArticles::dispatch();
    }
}

9. Podsumowanie

Masz teraz system wyszukiwania gotowy do produkcji z:

  • Wyszukiwaniem w czasie rzeczywistym
  • Zaawansowanym filtrowaniem i sortowaniem
  • Optymalizacją wydajności
  • Obsługą błędów
  • Czystym, łatwym w utrzymaniu kodem

To podejście utrzymuje Twój kod Laravel w czystości, testowalności i gotowości do produkcji.


Śledź mnie na LinkedIn po więcej wskazówek o Laravel i DevOps!

Chcesz dowiedzieć się więcej o implementacji wyszukiwania w Laravel? Daj znać w komentarzach poniżej!

Kod źródłowy

Pełną implementację i więcej przykładów znajdziesz w repozytorium GitHub.

Komentarze (0)
Zostaw komentarz

© 2025 Wszelkie prawa zastrzeżone.