gchaumont/elastico

Elasticsearch Eloquent ORM

Maintainers

Package info

github.com/gchaumont/elastico

pkg:composer/gchaumont/elastico

Statistics

Installs: 643

Dependents: 1

Suggesters: 0

Stars: 2

Open Issues: 5

dev-main 2026-06-21 19:43 UTC

README

Elasticsearch Eloquent ORM for Laravel. Extends Laravel's database layer so you can use Eloquent models, the query builder, aggregations, ILM policies, and scripted updates against Elasticsearch indices — with the same patterns you already know.

Requirements

  • PHP 8.2+
  • Laravel 11 or 12
  • Elasticsearch 8.x

Installation

composer require gchaumont/elastico

Configuration

Add a connection to config/database.php:

'connections' => [
    'elasticsearch' => [
        'driver' => 'elastic',
        'hosts'  => [env('ELASTICSEARCH_HOST', 'http://localhost:9200')],
        // Optional auth
        'username' => env('ELASTICSEARCH_USERNAME'),
        'password'  => env('ELASTICSEARCH_PASSWORD'),
        // Optional TLS
        'certificate' => env('ELASTICSEARCH_CERT'),
        // Optional Elastic Cloud
        'cloud' => env('ELASTIC_CLOUD_ID'),
    ],
],

Eloquent Models

Defining a model

use Elastico\Eloquent\Model;
use Elastico\Index\Config;
use Elastico\Index\Mappings;
use Elastico\Mapping\Field;
use Elastico\Mapping\FieldType;

class Article extends Model
{
    protected $connection = 'elasticsearch';

    // The Elasticsearch index name
    protected $table = 'articles';

    protected $fillable = ['title', 'status', 'published_at', 'author_id', 'score'];

    public static function getIndexConfig(): Config
    {
        return Config::make('articles')
            ->mapping(fn(Mappings $m) => $m->properties(
                Field::make(FieldType::keyword, 'status'),
                Field::make(FieldType::keyword, 'author_id'),
                Field::make(FieldType::text,    'title'),
                Field::make(FieldType::date,    'published_at'),
                Field::make(FieldType::float,   'score'),
            ));
    }
}

Creating and managing the index

php artisan elastic:index:create "App\Models\Article"
php artisan elastic:index:create "App\Models\Article" --fresh   # drop & recreate
php artisan elastic:index:delete "App\Models\Article"

Basic CRUD

// Create
$article = Article::create(['title' => 'Hello', 'status' => 'draft']);

// Find
$article = Article::find('doc-id');

// Update
$article->update(['status' => 'published']);

// Delete
$article->delete();

// Upsert (creates if missing, updates if present)
Article::upsert(
    [['_id' => 'doc-1', 'title' => 'Hello', 'status' => 'draft']],
    '_id'
);

Query Builder

All standard Laravel builder methods work. Elastico adds ES-specific operators:

Filtering

// Equality → term query
Article::where('status', 'published')->get();

// Range → range query
Article::where('score', '>', 80)->get();
Article::where('score', '>=', 80)->where('score', '<', 100)->get();
Article::whereBetween('score', [80, 100])->get();   // exclusive bounds (gt/lt)

// Terms → terms query
Article::whereIn('status', ['published', 'featured'])->get();
Article::whereNotIn('status', ['deleted'])->get();

// Null / exists
Article::whereNull('published_at')->get();
Article::whereNotNull('published_at')->get();

// NOT equal
Article::where('status', '<>', 'deleted')->get();

// OR
Article::where('status', 'published')
       ->orWhere('status', 'featured')
       ->get();

Full-text / like

// like → match query (strips % wildcards)
Article::where('title', 'like', '%elasticsearch%')->get();

Sorting

Article::orderBy('published_at', 'desc')->get();

// With missing value handling
Article::orderBy('score', 'desc', missing: '_last')->get();

// With nested sort
Article::orderBy('comments.likes', 'desc', nested: ['path' => 'comments'])->get();

Pagination and limits

Article::limit(20)->offset(40)->get();
Article::paginate(20);

// Total hits are preserved even with limit
$results = Article::limit(10)->get();
$results->total(); // full match count

Field selection

Article::select(['title', 'status'])->get();

// Exclude fields
Article::exclude(['body', 'raw_html'])->get();

Collapse

// Return one result per unique author
Article::collapse('author_id')->get();

Suggest

Article::suggest(
    name:  'title_suggest',
    text:  'elastcsearch',
    field: 'title',
    type:  'term',
    size:  5,
)->get();

KNN (vector search)

Article::knn(
    field:      'embedding',
    vector:     [0.1, 0.2, ...],
    k:          10,
    candidates: 100,
    filter:     ['term' => ['status' => ['value' => 'published']]],
)->get();

Runtime fields

use Elastico\Mapping\RuntimeField;
use Elastico\Mapping\FieldType;

Article::runtimeField('day_of_week', new RuntimeField(
    type:   FieldType::keyword,
    script: "emit(doc['published_at'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))",
))->get();

Cursor (point-in-time scrolling)

// Lazy collection — streams all matching docs without memory overhead
Article::where('status', 'published')->cursor()->each(function ($article) {
    // process
});

Multi-search

// Execute multiple queries in a single HTTP round-trip
[$published, $drafts] = $builder->getMany([
    Article::where('status', 'published'),
    Article::where('status', 'draft'),
]);

Counts and scalars

Article::where('status', 'published')->count();
Article::where('status', 'published')->sum('score');
Article::where('status', 'published')->avg('score');
Article::where('status', 'published')->min('score');
Article::where('status', 'published')->max('score');

Compiled query

// Inspect the Elasticsearch DSL payload
$payload = Article::where('status', 'published')->toSql();

Aggregations

Aggregations are added to the builder and accessed via the response collection.

Metric aggregations

use Elastico\Aggregations\Metric\Avg;
use Elastico\Aggregations\Metric\Sum;
use Elastico\Aggregations\Metric\Min;
use Elastico\Aggregations\Metric\Max;
use Elastico\Aggregations\Metric\Cardinality;
use Elastico\Aggregations\Metric\ValueCount;
use Elastico\Aggregations\Metric\Stats;
use Elastico\Aggregations\Metric\Percentiles;
use Elastico\Aggregations\Metric\TopHits;

$results = Article::where('status', 'published')
    ->addAggregation('avg_score',   new Avg('score'))
    ->addAggregation('total_views', new Sum('views'))
    ->addAggregation('authors',     new Cardinality('author_id'))
    ->limit(0)  // set to 0 if you only need aggregation results
    ->get();

$avgScore   = $results->aggregation('avg_score')->value();
$totalViews = $results->aggregation('total_views')->value();
$authCount  = $results->aggregation('authors')->value();

Bucket aggregations

use Elastico\Aggregations\Bucket\Terms;
use Elastico\Aggregations\Bucket\DateHistogram;
use Elastico\Aggregations\Bucket\Histogram;
use Elastico\Aggregations\Bucket\Range;
use Elastico\Aggregations\Bucket\Filter;
use Elastico\Aggregations\Bucket\Nested;

$results = Article::addAggregation(
    'by_status',
    new Terms('status', size: 10)
)->limit(0)->get();

foreach ($results->aggregation('by_status')->buckets() as $bucket) {
    echo "{$bucket['key']}: {$bucket['doc_count']}\n";
}

Nested aggregations

$byStatus = (new Terms('status', size: 10))
    ->addAggregation('avg_score', new Avg('score'));

$results = Article::addAggregation('by_status', $byStatus)->limit(0)->get();

foreach ($results->aggregation('by_status')->buckets() as $bucket) {
    $avg = $bucket->aggregation('avg_score')->value();
}

Scripted updates

Update documents with Painless scripts instead of setting values directly.

Increment a field

use Elastico\Scripting\Increment;

$article->update(new Increment(field: 'views', amount: 1));

// With additional fields to set alongside
$article->update(new Increment(field: 'views', amount: 1, extra: ['last_viewed_at' => now()]));

// Decrement
Article::where('status', 'active')->decrement('priority', 5);

Custom Painless script

use Elastico\Scripting\UpdateParams;

$article->update(
    (new UpdateParams(['multiplier' => 2]))
        ->withModel(['score' => 100])  // merges with model fields
);

Post-filter

Post-filter applies after aggregations are computed — useful for filtering results without affecting bucket counts:

$results = Article::addAggregation('by_status', new Terms('status'))
    ->postWhere('status', 'published')  // filters hits but not aggs
    ->get();

Index Lifecycle Management (ILM)

use Elastico\ILM\Policy;
use Elastico\ILM\Phases\HotPhase;
use Elastico\ILM\Phases\WarmPhase;
use Elastico\ILM\Phases\DeletePhase;
use Elastico\ILM\Actions\RolloverAction;
use Elastico\ILM\Actions\ReadOnlyAction;
use Elastico\ILM\Actions\DeleteAction;
use Elastico\ILM\Actions\ForceMergeAction;

$policy = new Policy(
    id: 'articles-policy',
    hot_phase: new HotPhase(
        min_age: '0ms',
        rollover: new RolloverAction(max_age: '7d', max_size: '50gb'),
    ),
    warm_phase: new WarmPhase(
        min_age: '7d',
        read_only: new ReadOnlyAction(),
        force_merge: new ForceMergeAction(max_num_segments: 1),
    ),
    delete_phase: new DeletePhase(
        min_age: '90d',
        delete: new DeleteAction(),
    ),
);

Relations

Elastico supports both Elasticsearch-native relations and hybrid relations to standard SQL models.

class Article extends Model
{
    // Elasticsearch-to-Elasticsearch
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class, 'article_id');
    }

    // Embedded documents
    public function metadata(): EmbedsOne
    {
        return $this->embedsOne(ArticleMetadata::class);
    }

    // Hybrid: Elasticsearch model → SQL model
    public function author(): BelongsTo
    {
        return $this->belongsTo(\App\Models\User::class, 'author_id');
    }
}

Testing

# Unit and feature tests (no Elasticsearch required)
composer test

# Start Elasticsearch
docker compose up -d

# Full suite including integration tests
vendor/bin/pest

The test suite is split into three layers:

Suite What it tests Needs ES?
Unit DSL query and aggregation compilation No
Feature Grammar compilation (builder → ES payload) No
Integration Actual queries against a live index Yes

Integration tests skip automatically with a clear message when Elasticsearch is not reachable. Configure the host via ELASTICSEARCH_HOST env var (defaults to http://localhost:9200).