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 è:
- Trasformare il modello in un documento JSON (solo campi necessari).
- Inviare a Elasticsearch un’operazione di index (upsert) quando il record cambia.
- 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=truea 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
indexcon 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:
- Crei
products_v1, indicizzi tutto - Punti l’alias
productsaproducts_v1 - Per una modifica: crei
products_v2, reindicizzi - 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 suhttp://localhost:9200, Kibana suhttp://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.