basemkhirat/elasticsearch
A Laravel package for working with Elasticsearch through a fluent query builder. It supports multiple connections, indices and documents, CRUD operations, bulk indexing, aggregations, and Eloquent-style models, simplifying search integration.
composer require basemkhirat/elasticsearch
Basemkhirat\Elasticsearch\ElasticsearchServiceProviderElasticsearch → Basemkhirat\Elasticsearch\Facades\Elasticsearchconfig/elasticsearch.php into config/ and enable $app->withFacades().php artisan vendor:publish --provider="Basemkhirat\Elasticsearch\ElasticsearchServiceProvider"
.env (the package reads these in config/elasticsearch.php):
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_SCHEME=http
ELASTICSEARCH_USER=
ELASTICSEARCH_PASS=
use Elasticsearch;
Elasticsearch::index('posts')->search()->get(); // or ->count()
use Basemkhirat\Elasticsearch\Model;
class Post extends Model
{
protected $index = 'posts';
protected $type = 'post'; // leave '' on ES 7+
}
$posts = Post::where('title', 'elasticsearch')
->orderBy('created_at', 'desc')
->take(20)
->get();
This where/orderBy/take/get triad is the 80% case — the rest of the package is variations on it.Where to look first: config/elasticsearch.php (hosts + default connection), then the Model base class for $index, $type, $connection, and $timestamps. The Query builder is the surface you will actually be poking at.
Model-per-index (recommended). One model per index keeps $index, $type, and casting logic in one place:
class Product extends Model
{
protected $index = 'products';
protected $type = 'product';
protected $connection = 'analytics';
protected $timestamps = true;
protected $attributes = ['stock' => 0];
}
Reads: build, then execute. get(), first(), paginate(), count(), exists(), chunk(), and scroll() all hydrate Model instances (except aggregations — see below):
Product::whereTerm('status', 'active')
->whereRange('price', ['gte' => 10, 'lte' => 100])
->whereIn('category_id', [1,2,3])
->select(['id','name','price'])
->orderBy('_score', 'desc')
->paginate(25);
Writes: single and bulk. create(), save(), update(), delete() mirror Eloquent; bulk() is what you use for ingestion:
$model->fill([...])->save();
$model->update(['stock' => 5]);
$model->delete();
Product::bulk(function ($q) use ($rows) {
foreach ($rows as $row) {
$q->index('products')->type('product')->id($row['id'])->insert($row);
}
});
Bulk is the only sane path for anything past a few hundred docs — do not loop ->save().
Raw DSL escape hatch. When the fluent builder cannot express it, use search() and pass a body:
$result = Product::search()
->body([
'query' => ['function_score' => [/* ... */]],
'aggs' => [/* ... */],
])
->get();
Inspect what the builder is actually sending with ->body() / ->toSql() before calling get(). This is the single most useful debugging habit with this package.
Aggregations are nested builders hanging off aggregate()/aggs():
$stats = Order::whereTerm('status', 'paid')
->aggregate('terms', 'category_id')
->bucket('revenue')->sum('total')
->bucket('avg_order')->avg('total')
->get(); // returns a raw array, NOT hydrated models
Index and mapping lifecycle. Keep schema in migrations or an Artisan command, not in model classes:
Elasticsearch::index('products')->create(['settings' => [...], 'mappings' => [...]]);
Elasticsearch::index('products')->putMapping([...]);
Elasticsearch::index('products')->exists();
Elasticsearch::index('products')->delete();
Aliases + reindex() for zero-downtime rebuilds are the standard prod workflow.
Multi-cluster / multi-tenant. Two patterns: set $connection per model, or flip the facade per tenant:
Elasticsearch::connection('tenant_42')->index('products')->search()->get();
Prefer model-level for read/write separation; facade-level for per-tenant indexes.
Repository/service layer. Wrap each model in a small repository exposing typed methods (searchProducts(array $filters), reindexProduct($id)). Do not let controllers touch Elasticsearch:: directly — the builder API is too easy to misuse under time pressure, and it gives you one place to swap in search_after or raw DSL later.
$type is ignored or rejected. Budget for a rewrite to elasticsearch/elasticsearch or laravel/scout + a driver if you are on anything modern.$type on ES 7+. Either omit it or set ''; leaving a legacy type in will cause errors only at query time, not boot.where() is a match query, not a term filter. For exact/keyword lookups use whereTerm(). This is the most common source of "empty results in dev, works in prod" bugs.save()/insert(). In tests call Elasticsearch::index('x')->refresh('true') (or wait_for) between write and read, and consider a per-test unique index suffix to avoid cross-test bleed.get(), first(), paginate(), chunk() → Model instances. Aggregations, raw(), and search()->get() on raw bodies → arrays. Pick a return shape per repository method and stick to it.from/size is bounded by index.max_result_window (default 10k). paginate() beyond that silently fails or errors. For deep pagination use scroll() (snapshots, exports) or search_after (infinite scroll) via raw body().refresh_interval: -1 + number_of_replicas: 0 during the load and restore after — otherwise you will melt the cluster.date mapping. $timestamps = true writes created_at/updated_at; if the mapping is text/keyword you will get rejected docs and only find out via bulk error responses.bulk() returns the raw response with a errors flag; silently ignoring it means half your data is missing. Loop over items[] and log error entries.whereNested(). where('comments.author', 'x') on a nested-mapped field returns nothing sensible — always wrap with a nested query._source filtering is not automatic. Use select() or ->source([...]); otherwise you drag full documents around and hydrate models you throw away.enableQueryLog() / getQueryLog() (if present in your version), and ->body() to capture the exact JSON DSL. Reproduce it in Kibana Dev Tools — the builder hides mistakes well.orderBy('price', 'desc') on an unmapped/text field fails at query time; for numeric sorts ensure the field is integer/float/keyword.hosts array form (['https://user:pass@host:9200']) bypasses the host/port/user/pass keys; do not mix them. Set ELASTICSEARCH_SCHEME explicitly if you terminate TLS at a proxy, or you get a confusing connection reset.Basemkhirat\Elasticsearch\Model for shared scopes; subclass Query if you need new DSL verbs; a custom Collection is possible but rarely worth it — hydrate into your own value objects instead.Elasticsearch::connection() works before debugging anything else.How can I help you explore Laravel packages today?