Usare Elasticsearch in un’applicazione web Laravel con Docker e Docker Compose

Elasticsearch è un motore di ricerca e analisi distribuito, adatto a ricerche full‑text, filtraggio, aggregazioni e autocomplete. In un progetto Laravel è comune usarlo per sostituire (o affiancare) le ricerche SQL tradizionali quando servono prestazioni e funzioni avanzate. In questo articolo configuriamo un ambiente Docker con Laravel e Elasticsearch, definiamo un flusso di indicizzazione, e implementiamo query tipiche lato applicazione.

Obiettivi e prerequisiti

  • Avviare Laravel e Elasticsearch con Docker Compose (più Kibana opzionale).
  • Configurare una connessione sicura e riproducibile per sviluppo locale.
  • Creare un indice con mapping sensato (testo, keyword, date, numeri) e supporto per autocomplete.
  • Indicizzare dati da Eloquent e fare ricerche con filtri, ordinamenti e paginazione.
  • Gestire reindicizzazione e aggiornamenti tramite job in coda.

Prerequisiti: Docker Desktop (o Docker Engine + Compose), PHP/Laravel di base, familiarità con Eloquent. Gli esempi assumono Linux/macOS; su Windows con WSL2 la logica è identica.

Architettura della soluzione

L’idea è avere più container collegati su una rete Compose:

  • app: PHP-FPM + codice Laravel
  • nginx: server web per servire l’applicazione
  • db: MySQL/MariaDB (o Postgres) per i dati sorgente
  • elasticsearch: motore di ricerca
  • kibana (opzionale): UI per ispezionare indici e query

In sviluppo locale è comodo disabilitare la security di Elasticsearch oppure usare credenziali note. Qui usiamo la modalità single-node con security disabilitata per semplicità; in produzione la security va attivata.

Docker Compose: servizi Laravel + Elasticsearch

Crea una directory di progetto (o usa un progetto Laravel esistente) e aggiungi una configurazione Compose. Di seguito un esempio completo con healthcheck e volumi persistenti.

services:
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "8080:80"
    volumes:
      - ./:/var/www/html:delegated
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_started
    networks:
      - appnet

  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./:/var/www/html:delegated
    environment:
      APP_ENV: local
      APP_DEBUG: "true"
      DB_HOST: db
      DB_PORT: 3306
      ELASTICSEARCH_HOST: http://elasticsearch:9200
    depends_on:
      db:
        condition: service_healthy
      elasticsearch:
        condition: service_healthy
    networks:
      - appnet

  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_USER: laravel
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: rootsecret
    ports:
      - "33060:3306"
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-prootsecret"]
      interval: 5s
      timeout: 5s
      retries: 20
    networks:
      - appnet

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
      - http.host=0.0.0.0
    ports:
      - "9200:9200"
    volumes:
      - esdata:/usr/share/elasticsearch/data
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200/_cluster/health?wait_for_status=yellow&timeout=5s || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 30
    networks:
      - appnet

  kibana:
    image: docker.elastic.co/kibana/kibana:8.13.4
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy
    networks:
      - appnet

networks:
  appnet:

volumes:
  dbdata:
  esdata:

Nota: le versioni di Elasticsearch e Kibana devono essere compatibili (stesso major e in pratica stessa minor). Per macchine con poca RAM, riduci ulteriormente ES_JAVA_OPTS, ma evita di scendere troppo per non degradare le prestazioni.

Configurazione Nginx

Crea docker/nginx/default.conf per puntare a PHP-FPM.

server {
  listen 80;
  server_name _;
  root /var/www/html/public;

  index index.php;

  location / {
    try_files $uri $uri/ /index.php?$query_string;
  }

  location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass app:9000;
  }

  location ~ /\.ht {
    deny all;
  }
}

Dockerfile per l’app Laravel

Un Dockerfile minimale basato su PHP-FPM con estensioni tipiche e Composer.

FROM php:8.3-fpm-alpine

WORKDIR /var/www/html

RUN apk add --no-cache     bash curl git icu-dev libzip-dev oniguruma-dev     && docker-php-ext-install pdo pdo_mysql intl zip

# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# Consigliato: utente non-root (opzionale in dev)
RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
USER app

Avvio dell’ambiente

Avvia i servizi e installa le dipendenze PHP (se non lo hai già fatto).

docker compose up -d --build

docker compose exec app composer install
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate

Verifica Elasticsearch:

curl -s http://localhost:9200 | jq .
curl -s "http://localhost:9200/_cluster/health?pretty"

Collegare Laravel a Elasticsearch

Ci sono due approcci comuni:

  • Client ufficiale elasticsearch-php: controlli ogni query/mapping, ottimo per casi avanzati.
  • Laravel Scout + driver Elasticsearch: integrazione più “Laravel‑like” per indicizzazione e ricerca sui modelli.

Qui usiamo il client ufficiale per mantenere tutto esplicito e riproducibile.

Installazione pacchetto

docker compose exec app composer require elasticsearch/elasticsearch:^8.0

Variabili d’ambiente

In .env aggiungi:

ELASTICSEARCH_HOST=http://elasticsearch:9200
ELASTICSEARCH_INDEX=products

Service provider e binding del client

Crea un service provider dedicato, ad esempio app/Providers/ElasticsearchServiceProvider.php.

<?php

namespace App\Providers;

use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\ServiceProvider;

class ElasticsearchServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Client::class, function () {
            $host = config('services.elasticsearch.host');

            return ClientBuilder::create()
                ->setHosts([$host])
                ->build();
        });
    }
}

Registra il provider in config/app.php (se non usi auto-discovery) e aggiungi la config. In config/services.php:

return [
    // ...
    'elasticsearch' => [
        'host' => env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
        'index' => env('ELASTICSEARCH_INDEX', 'products'),
    ],
];

Creare l’indice: mapping e analyzer

Prima di indicizzare, definisci un mapping con campi “testo” (analizzati) e “keyword” (non analizzati). Per l’autocomplete è comune usare un analyzer edge_ngram.

Comando Artisan per bootstrap dell’indice

Crea un comando: php artisan make:command ElasticsearchCreateIndex.

<?php

namespace App\Console\Commands;

use Elastic\Elasticsearch\Client;
use Illuminate\Console\Command;

class ElasticsearchCreateIndex extends Command
{
    protected $signature = 'es:create-index {--drop : Elimina l\'indice se esiste}';
    protected $description = 'Crea l\'indice Elasticsearch con mapping e analyzer';

    public function handle(Client $client): int
    {
        $index = config('services.elasticsearch.index');

        if ($this->option('drop')) {
            $client->indices()->delete(['index' => $index, 'ignore_unavailable' => true]);
            $this->info("Indice eliminato: {$index}");
        }

        $exists = $client->indices()->exists(['index' => $index])->asBool();
        if ($exists) {
            $this->warn("Indice già esistente: {$index}");
            return self::SUCCESS;
        }

        $params = [
            'index' => $index,
            'body' => [
                'settings' => [
                    'analysis' => [
                        'filter' => [
                            'autocomplete_filter' => [
                                'type' => 'edge_ngram',
                                'min_gram' => 2,
                                'max_gram' => 20
                            ],
                        ],
                        'analyzer' => [
                            'autocomplete' => [
                                'type' => 'custom',
                                'tokenizer' => 'standard',
                                'filter' => ['lowercase', 'autocomplete_filter'],
                            ],
                            'autocomplete_search' => [
                                'type' => 'custom',
                                'tokenizer' => 'standard',
                                'filter' => ['lowercase'],
                            ],
                        ],
                    ],
                ],
                'mappings' => [
                    'properties' => [
                        'id' => ['type' => 'keyword'],
                        'name' => [
                            'type' => 'text',
                            'analyzer' => 'autocomplete',
                            'search_analyzer' => 'autocomplete_search',
                            'fields' => [
                                'raw' => ['type' => 'keyword']
                            ],
                        ],
                        'description' => ['type' => 'text'],
                        'price' => ['type' => 'double'],
                        'category' => ['type' => 'keyword'],
                        'is_active' => ['type' => 'boolean'],
                        'created_at' => ['type' => 'date'],
                    ],
                ],
            ],
        ];

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

        $this->info("Indice creato: {$index}");
        return self::SUCCESS;
    }
}

Esegui:

docker compose exec app php artisan es:create-index --drop

Indicizzazione dei dati da Eloquent

Supponiamo di avere un modello Product in database. L’idea è:

  1. Trasformare il modello in un documento JSON (solo campi necessari).
  2. Inviare a Elasticsearch un’operazione di index (upsert) quando il record cambia.
  3. Cancellare dal search index quando il record viene eliminato o disattivato.

Repository/Service per le operazioni Elasticsearch

Crea un service app/Services/ProductSearchIndex.php.

<?php

namespace App\Services;

use App\Models\Product;
use Elastic\Elasticsearch\Client;

class ProductSearchIndex
{
    public function __construct(private Client $client) {}

    public function index(Product $product): void
    {
        $index = config('services.elasticsearch.index');

        $body = [
            'id' => (string) $product->id,
            'name' => $product->name,
            'description' => $product->description,
            'price' => (float) $product->price,
            'category' => (string) $product->category_slug,
            'is_active' => (bool) $product->is_active,
            'created_at' => optional($product->created_at)->toAtomString(),
        ];

        $this->client->index([
            'index' => $index,
            'id' => (string) $product->id,
            'body' => $body,
            'refresh' => false,
        ]);
    }

    public function delete(Product $product): void
    {
        $index = config('services.elasticsearch.index');

        $this->client->delete([
            'index' => $index,
            'id' => (string) $product->id,
            'ignore' => [404],
            'refresh' => false,
        ]);
    }
}

Observer per indicizzazione automatica

Un observer mantiene sincronizzato l’indice con il database. In produzione è preferibile usare code (queue) per evitare latenza sulle richieste web.

docker compose exec app php artisan make:observer ProductObserver --model=Product
<?php

namespace App\Observers;

use App\Jobs\IndexProductInElasticsearch;
use App\Jobs\DeleteProductFromElasticsearch;
use App\Models\Product;

class ProductObserver
{
    public function saved(Product $product): void
    {
        if ($product->is_active) {
            IndexProductInElasticsearch::dispatch($product->id);
        } else {
            DeleteProductFromElasticsearch::dispatch($product->id);
        }
    }

    public function deleted(Product $product): void
    {
        DeleteProductFromElasticsearch::dispatch($product->id);
    }
}

Registra l’observer (ad esempio in AppServiceProvider).

use App\Models\Product;
use App\Observers\ProductObserver;

public function boot(): void
{
    Product::observe(ProductObserver::class);
}

Job in coda per indicizzazione

Crea due job:

docker compose exec app php artisan make:job IndexProductInElasticsearch
docker compose exec app php artisan make:job DeleteProductFromElasticsearch
<?php

namespace App\Jobs;

use App\Models\Product;
use App\Services\ProductSearchIndex;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class IndexProductInElasticsearch implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $productId) {}

    public function handle(ProductSearchIndex $indexer): void
    {
        $product = Product::query()->find($this->productId);

        if (!$product) {
            return;
        }

        if (!$product->is_active) {
            return;
        }

        $indexer->index($product);
    }
}
<?php

namespace App\Jobs;

use App\Models\Product;
use App\Services\ProductSearchIndex;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class DeleteProductFromElasticsearch implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $productId) {}

    public function handle(ProductSearchIndex $indexer): void
    {
        $product = new Product();
        $product->id = $this->productId;

        $indexer->delete($product);
    }
}

Per sviluppo puoi usare il driver sync (niente worker). Per un flusso realistico abilita un driver a code (database/redis) e avvia un worker:

docker compose exec app php artisan queue:table
docker compose exec app php artisan migrate

docker compose exec app php artisan queue:work

Reindicizzazione completa (bulk)

Quando cambi mapping/analyzer o devi ricostruire l’indice, serve una reindicizzazione completa. Con l’API bulk eviti una richiesta HTTP per documento.

Comando Artisan per reindicizzare

docker compose exec app php artisan make:command ElasticsearchReindexProducts
<?php

namespace App\Console\Commands;

use App\Models\Product;
use Elastic\Elasticsearch\Client;
use Illuminate\Console\Command;

class ElasticsearchReindexProducts extends Command
{
    protected $signature = 'es:reindex-products {--chunk=500}';
    protected $description = 'Reindicizza tutti i prodotti attivi usando Bulk API';

    public function handle(Client $client): int
    {
        $index = config('services.elasticsearch.index');
        $chunk = (int) $this->option('chunk');

        $this->info("Reindicizzazione su indice: {$index}");

        Product::query()
            ->where('is_active', true)
            ->orderBy('id')
            ->chunkById($chunk, function ($products) use ($client, $index) {
                $body = [];

                foreach ($products as $product) {
                    $body[] = [
                        'index' => [
                            '_index' => $index,
                            '_id' => (string) $product->id,
                        ],
                    ];

                    $body[] = [
                        'id' => (string) $product->id,
                        'name' => $product->name,
                        'description' => $product->description,
                        'price' => (float) $product->price,
                        'category' => (string) $product->category_slug,
                        'is_active' => (bool) $product->is_active,
                        'created_at' => optional($product->created_at)->toAtomString(),
                    ];
                }

                $client->bulk([
                    'index' => $index,
                    'body' => $body,
                    'refresh' => false,
                ]);

                $this->output->write('.');
            });

        $this->info("\nFatto.");
        return self::SUCCESS;
    }
}

Esegui la sequenza tipica:

docker compose exec app php artisan es:create-index --drop
docker compose exec app php artisan es:reindex-products

Ricerca: full-text, filtri, ordinamento e paginazione

Ora implementiamo un endpoint di ricerca. Esempio: GET /search/products?q=iphone&category=phones&min_price=100&max_price=999&sort=price_asc&page=1.

Search service

Crea app/Services/ProductSearch.php.

<?php

namespace App\Services;

use Elastic\Elasticsearch\Client;

class ProductSearch
{
    public function __construct(private Client $client) {}

    public function search(array $filters): array
    {
        $index = config('services.elasticsearch.index');

        $q = (string) ($filters['q'] ?? '');
        $category = $filters['category'] ?? null;
        $min = isset($filters['min_price']) ? (float) $filters['min_price'] : null;
        $max = isset($filters['max_price']) ? (float) $filters['max_price'] : null;

        $page = max(1, (int) ($filters['page'] ?? 1));
        $perPage = min(50, max(1, (int) ($filters['per_page'] ?? 12)));
        $from = ($page - 1) * $perPage;

        $sort = match (($filters['sort'] ?? null)) {
            'price_asc' => [['price' => ['order' => 'asc']]],
            'price_desc' => [['price' => ['order' => 'desc']]],
            'newest' => [['created_at' => ['order' => 'desc']]],
            default => ['_score'],
        };

        $must = [];
        $filter = [
            ['term' => ['is_active' => true]],
        ];

        if ($q !== '') {
            $must[] = [
                'multi_match' => [
                    'query' => $q,
                    'fields' => ['name^3', 'description'],
                    'type' => 'best_fields',
                    'operator' => 'and',
                ],
            ];
        } else {
            $must[] = ['match_all' => (object) []];
        }

        if ($category) {
            $filter[] = ['term' => ['category' => (string) $category]];
        }

        if ($min !== null || $max !== null) {
            $range = [];
            if ($min !== null) $range['gte'] = $min;
            if ($max !== null) $range['lte'] = $max;

            $filter[] = ['range' => ['price' => $range]];
        }

        $params = [
            'index' => $index,
            'body' => [
                'from' => $from,
                'size' => $perPage,
                'query' => [
                    'bool' => [
                        'must' => $must,
                        'filter' => $filter,
                    ],
                ],
                'sort' => $sort,
                'track_total_hits' => true,
            ],
        ];

        $resp = $this->client->search($params)->asArray();

        $total = (int) ($resp['hits']['total']['value'] ?? 0);
        $items = array_map(fn ($hit) => $hit['_source'], $resp['hits']['hits'] ?? []);

        return [
            'total' => $total,
            'page' => $page,
            'per_page' => $perPage,
            'items' => $items,
        ];
    }
}

Controller e route

docker compose exec app php artisan make:controller ProductSearchController
<?php

namespace App\Http\Controllers;

use App\Services\ProductSearch;
use Illuminate\Http\Request;

class ProductSearchController extends Controller
{
    public function __invoke(Request $request, ProductSearch $search)
    {
        $data = $search->search($request->only([
            'q', 'category', 'min_price', 'max_price', 'sort', 'page', 'per_page'
        ]));

        return response()->json($data);
    }
}

In routes/web.php o routes/api.php:

use App\Http\Controllers\ProductSearchController;

Route::get('/search/products', ProductSearchController::class);

Autocomplete: suggerimenti veloci mentre si digita

Avendo definito l’analyzer edge_ngram sul campo name, puoi creare un endpoint che restituisce suggerimenti rapidi. Una tecnica semplice è usare una query match sul campo name e limitare i risultati.

public function suggest(string $prefix, int $limit = 8): array
{
    $index = config('services.elasticsearch.index');

    $resp = $this->client->search([
        'index' => $index,
        'body' => [
            'size' => $limit,
            '_source' => ['id', 'name', 'price', 'category'],
            'query' => [
                'bool' => [
                    'filter' => [['term' => ['is_active' => true]]],
                    'must' => [[
                        'match' => [
                            'name' => [
                                'query' => $prefix,
                                'operator' => 'and',
                            ],
                        ],
                    ]],
                ],
            ],
        ],
    ])->asArray();

    return array_map(fn ($hit) => $hit['_source'], $resp['hits']['hits'] ?? []);
}

Se vuoi un comportamento più “da suggerimento” (con gestione dedicata), Elasticsearch offre anche il “completion suggester”. In quel caso aggiungi un campo suggest di tipo completion nel mapping e indicizzi input/weight. È più lavoro iniziale ma dà un controllo migliore su ranking e fuzzy.

Strategie operative: refresh, consistenza e performance

  • refresh: evitare refresh=true a ogni write. In dev può aiutare a vedere subito i risultati, ma in generale degrada le performance.
  • bulk: usa bulk per import massivi o reindicizzazione.
  • queue: indicizza tramite code per non rallentare le richieste web.
  • idempotenza: l’operazione index con stesso ID sovrascrive il documento: è un upsert semplice e robusto.
  • source of truth: il database resta la fonte dei dati; Elasticsearch è una proiezione ottimizzata per la ricerca.

Debug con Kibana e cURL

Kibana su http://localhost:5601 (se avviato) permette di ispezionare indici e fare query dal pannello “Dev Tools”. Con cURL:

# info indice
curl -s "http://localhost:9200/products?pretty"

# mapping
curl -s "http://localhost:9200/products/_mapping?pretty"

# esempio query
curl -s "http://localhost:9200/products/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 5,
    "query": {
      "multi_match": {
        "query": "iphone",
        "fields": ["name^3","description"]
      }
    }
  }'

Gestione dei cambi di mapping in modo sicuro (alias e versioning)

In produzione, cambiare mapping di un indice esistente è spesso impossibile o limitato. Una pratica robusta è creare indici versionati e un alias stabile:

  1. Crei products_v1, indicizzi tutto
  2. Punti l’alias products a products_v1
  3. Per una modifica: crei products_v2, reindicizzi
  4. Switch atomico dell’alias a products_v2

Questo evita downtime e risultati incoerenti durante la reindicizzazione.

Hardening e note per la produzione

  • Security: attiva xpack security (TLS, utenti/ruoli). In Compose di sviluppo spesso è disabilitata per semplicità, ma in produzione va abilitata.
  • Risorse: assegna memoria adeguata e monitora heap. Evita swap.
  • Backup: usa snapshot repository (S3, filesystem, ecc.) per backup degli indici.
  • Osservabilità: log, metriche e alert su latenza query, heap, GC, disk watermarks.
  • Compatibilità: mantieni allineate le versioni del client e del cluster (major compatibile).

Checklist finale

  • Compose avviato: Laravel raggiungibile su http://localhost:8080, Elasticsearch su http://localhost:9200, Kibana su http://localhost:5601.
  • Indice creato con mapping/analyzer: php artisan es:create-index --drop
  • Dati indicizzati: observer + job oppure reindicizzazione bulk
  • Endpoint ricerca: filtri, ordinamento, paginazione
  • Endpoint suggerimenti: autocomplete su name

Da qui puoi estendere la soluzione con sinonimi, stemming per lingua italiana, ranking personalizzato (function_score), aggregazioni per faccette (categorie, range prezzo) e un workflow completo alias+versioni per rilasci in sicurezza.

Torna su