james.xue/laravel-elasticsearch
A Laravel package for integrating Elasticsearch into your application, offering a convenient way to build and run Elasticsearch queries from PHP. It aims to simplify index management and search workflows within Laravel projects.
Install and publish the config, then point it at a cluster before writing any application code:
composer require xiaoxuan6/laravel-elasticsearch
php artisan vendor:publish --tag=elasticsearch # confirm the tag in the package's config/ dir
Set the connection in .env (ELASTICSEARCH_HOST, port/user/pass, or a full URL) and confirm the shape of config/elasticsearch.php. The first thing to run is a connectivity smoke test, not a real query:
use Elastic\Elasticsearch\Client;
$info = app(Client::class)->info(); // 2.3.1: namespaced under Elastic\Elasticsearch
Where to look first:
elasticsearch/elasticsearch client options are exposed, so it tells you what the package supports out of the box vs. what you must build yourself.First realistic use case: index an Eloquent model and run one search from a controller. Get that round trip working (write → refresh → search → hit) before designing indices, because it validates host config, auth, TLS, and version compatibility all at once.
Note on 2.3.1: this release locks the dependency to the 8.x client line and follows the elasticsearch-php 8.0 namespace reorganisation. If your app currently references Elasticsearch\Client / Elasticsearch\ClientBuilder, that is now Elastic\Elasticsearch\Client / Elastic\Elasticsearch\ClientBuilder. Treat the upgrade as a namespace migration, not a patch bump.
Inject the client, wrap it once. Don't scatter raw client calls through controllers. Bind a small repository/service around the client so query construction, index naming, and error translation live in one place:
final class ProductSearch
{
public function __construct(private Client $client) {}
public function search(string $q, int $size = 20): array
{
return $this->client->search([
'index' => $this->index(),
'body' => ['query' => ['multi_match' => ['query' => $q, 'fields' => ['name^3', 'description']]]],
'size' => $size,
])->asArray();
}
}
Index naming per environment/tenant. Derive the index name from config plus an environment or tenant suffix (products_production, products_tenant_42) in one helper. This makes blue/green reindexing and cleanup tractable and prevents local runs from writing into shared indices.
Model observers for writes, queue for backfills. Use saved/deleted model events to push single-document updates, and a queued job for bulk backfills. For bulk, use the bulk API with chunked array_chunk($docs, 500) rather than one request per model — the per-request overhead dominates otherwise. Make the job idempotent (index with an explicit _id derived from the primary key rather than create), so retries don't produce duplicates or 409s.
Keep index creation in migrations or a command, not in request paths. A dedicated artisan command (or migration) that creates mappings and settings is easier to review and re-run than lazy creation on first write. Explicit mappings beat dynamic mapping for anything you will sort or aggregate on.
Testing. Bind a fake against Elastic\Elasticsearch\Client in the container for unit tests so feature tests never touch a live cluster; reserve integration tests for the real client, and hit a throwaway index that the test setup deletes in tearDown. Assert on the query body passed to the client rather than on search relevance — relevance assertions are brittle.
Health and failure handling. Wrap client calls in a service that catches client exceptions and degrades (empty results + log) rather than letting an Elasticsearch outage 500 the whole page. Elasticsearch should be an accelerator, not a hard dependency, for most read paths.
8.x. Elasticsearch\Client, Elasticsearch\ClientBuilder, and most Elasticsearch\Common\Exceptions\* references must become Elastic\Elasticsearch\* / Elastic\Elasticsearch\Exception\*. Grep the whole app, including tests, factories, and any custom service providers, before bumping.Missing404Exception-style classes no longer exist; you catch ClientResponseException and inspect getResponse()->getStatusCode(). Any catch (Missing404Exception $e) blocks will silently stop matching — a particularly nasty failure mode because it fails open.handler from the 7.x/RingPHP era are gone and replaced by PSR-18 HTTP client wiring. If you published the config under 2.3.0 or earlier, diff it against the new package config rather than assuming it still applies.8.x means any other package in the app requiring the 7.x client blocks the upgrade. Check composer why elasticsearch/elasticsearch before assuming a smooth bump, and be aware the server side must be 7.x/8.x-compatible.refresh explicitly or tolerate staleness. Don't add refresh: true to production write paths as a habit — it has a real indexing cost.200 from the bulk endpoint does not mean every document indexed; per-item errors live in items[].error. Always inspect the response and log/retry failures. This is the single most common source of "why is this record missing from search".from/size pagination breaks past index.max_result_window (default 10,000). For deep pagination use search_after with a stable sort, and make that a deliberate design choice rather than a bug report later.curl to _search when results look wrong. Distinguishing "bad query" from "bad mapping" from "stale index" is much faster with the body in hand.ClientBuilder in a service provider and bind it to the container — the package resolves the client from the container, so an explicit binding typically overrides it cleanly and keeps you off a fork.How can I help you explore Laravel packages today?