php-es-orm / es-orm
An Eloquent-style, type-safe, production-ready Elasticsearch ORM for PHP, with full Query DSL, Aggregations, Search After, PIT, Bulk, Alias and Reindex support.
Requires
- php: ^8.2
- ext-json: *
- elasticsearch/elasticsearch: ^9.5
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.60
- guzzlehttp/guzzle: ^7.8
- illuminate/console: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- mockery/mockery: ^1.6
- phpstan/phpstan: ^1.11 || ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
- symfony/var-dumper: ^6.4 || ^7.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client is required to talk to Elasticsearch (^7.8).
- illuminate/console: Required for the Artisan commands (^10|^11|^12).
- illuminate/support: Required for the Laravel integration (^10|^11|^12).
- symfony/var-dumper: Required for Query Builder dump()/dd() debugging.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-01 11:54:09 UTC
README
An Eloquent-style, type-safe, production-ready Elasticsearch ORM for PHP.
php-es-orm gives you a Laravel Eloquent-like developer experience on top of
Elasticsearch, without hiding what Elasticsearch can do. It keeps the native
Query DSL, Aggregations, search_after, Point In Time (PIT), Bulk, Aliases,
Reindex and everything else within reach — and never pretends Elasticsearch is
MySQL.
- Eloquent-style models:
Model,find,create,save,update,delete, … - A fluent, chainable Query Builder
- Full Elasticsearch Query DSL, including Bool and Nested queries
- A first-class raw DSL escape hatch, mixable with the ORM API
- Aggregations, Highlight, Suggest, Geo, Scroll, Search After, PIT
- Bulk API for batch create/update/delete (never a loop of single calls)
- Index / Mapping / Alias / Reindex / Migration management
- Casts, accessors & mutators, scopes, soft deletes, events, observers, relations
- Laravel integration as an optional adapter: the core has zero Laravel dependency
- PHP 8.2+, PSR-4 / PSR-12, PHPStan clean
Table of contents
- Requirements
- Installation
- Configuration
- Connecting to Elasticsearch
- Creating a Model
- CRUD
- Query Builder
- Query DSL
- Bool queries
- Nested queries
- Raw DSL
- Source filtering
- Sorting
- Operators
- Pagination
- Aggregations
- Highlight
- Suggest
- Geo search
- Scroll
- Bulk
- MGET & Multi Search
- Casts
- Accessors & Mutators
- Fillable / Guarded
- Scopes
- Soft deletes
- Events & Observers
- Relations
- Mapping
- Index management
- Alias management
- Reindex
- Index migrations
- Caching
- Debugging
- Error handling
- Laravel integration
- Docker & tooling
- Testing
- FAQ
- Architecture
Requirements
- PHP 8.2+
ext-json- The official
elasticsearch/elasticsearchclient (^9.0), which is installed by Composer - Elasticsearch 8.x (9.x compatibility is preserved where possible)
Installation
composer require php-es-orm/es-orm
You also need a PSR-18 HTTP client (Guzzle is the usual choice):
composer require guzzlehttp/guzzle
Configuration
The package ships with a configuration file you can copy into your project:
cp vendor/php-es-orm/es-orm/config/es_orm.php config/es_orm.php
<?php declare(strict_types=1); return [ 'default' => 'default', 'connections' => [ 'default' => [ 'hosts' => ['http://localhost:9200'], ], // A second connection, used by models declaring `protected string $connection = 'logs';` 'logs' => [ 'hosts' => ['http://localhost:9201'], 'api_key' => 'id:key', ], ], 'retry' => [ 'max_retries' => 3, 'retry_delay' => 100, // milliseconds 'backoff' => 2, 'max_delay' => 2000, ], 'logging' => [ 'enabled' => true, 'max_entries' => 100, ], 'cache' => [ 'enabled' => false, 'driver' => EsOrm\Support\Cache\ArrayCacheStore::class, 'prefix' => 'es_orm:', 'ttl' => 60, ], 'pagination' => [ 'default_size' => 15, 'max_size' => 10000, 'track_total_hits' => true, 'scroll_keep_alive' => '1m', 'pit_keep_alive' => '1m', ], 'bulk' => [ 'chunk_size' => 1000, 'refresh' => false, ], 'index' => [ 'default_settings' => [ 'number_of_shards' => 1, 'number_of_replicas' => 1, ], ], 'mapping' => [ 'strict' => false, ], 'migrations' => [ 'path' => 'database/es-migrations', 'repository' => 'es-orm-migrations.json', ], ];
Supported connection keys: hosts, cloud_id, api_key (+ optional
api_key_id), username/password, bearer_token, http_client,
http_client_options, timeout, connect_timeout, ssl_certificate,
ssl_key, ssl_ca, ssl_verification, logger.
Authentication recap — Basic Auth (
username+password), API Key (api_key), Bearer Token (bearer_token), Cloud ID (cloud_id) and TLS (ssl_*) are all supported.
Connecting to Elasticsearch
Standalone PHP
Models resolve a shared connection manager lazily from config/es_orm.php
(relative to the current working directory) or from the file pointed at by the
ES_ORM_CONFIG environment variable:
export ES_ORM_CONFIG=/absolute/path/to/es_orm.php
You can also inject the manager explicitly (recommended in tests and in custom bootstrapping):
use EsOrm\Client\ConnectionManager; use EsOrm\Model\Model; use EsOrm\Support\Config; Model::setConnectionManager(new ConnectionManager(Config::fromFile('config/es_orm.php'))); // or from an array Model::setConnectionManager(ConnectionManager::fromArray([ 'default' => 'default', 'connections' => ['default' => ['hosts' => ['http://localhost:9200']]], ]));
Laravel
Nothing to do beyond installing the package — the service provider is auto-discovered. See Laravel integration.
Creating a Model
<?php declare(strict_types=1); namespace App\Models; use EsOrm\Model\Model; final class User extends Model { protected string $index = 'users'; protected array $fillable = [ 'name', 'email', 'age', 'status', ]; protected array $casts = [ 'age' => 'integer', 'active' => 'boolean', 'metadata' => 'array', 'created_at' => 'datetime', ]; }
The index defaults to the snake-cased class name plus s when $index is not
set.
CRUD
use App\Models\User; // Create $user = User::create([ 'name' => 'Tom', 'email' => 'tom@example.com', 'age' => 20, 'status' => 'active', ]); // Read $user = User::find(1); // ?User $user = User::findOrFail(1); // User (throws ModelNotFoundException) $user = User::first(); $user = User::firstOrFail(); // Update $user = User::find(1); $user->name = 'Tomás'; $user->save(); $user->update(['status' => 'inactive']); // fill + save // Delete $user->delete(); // Helpers $user->exists(); // bool $user->getKey(); // mixed $user->refresh(); // re-read from Elasticsearch $user->toArray(); $user->toJson();
Find-or-create helpers
// Find by attributes or return an unsaved instance hydrated with the values $user = User::query()->firstOrNew(['email' => 'tom@example.com'], ['name' => 'Tom']); // Find by attributes or create the document (single index request when missing) $user = User::query()->firstOrCreate(['email' => 'tom@example.com'], ['name' => 'Tom']); // Find by attributes and create/update with the given values $user = User::query()->updateOrCreate(['email' => 'tom@example.com'], ['age' => 21]);
Atomic counters
increment() / decrement() write the change with a single Painless script
update (retry_on_conflict included), so concurrent writers never lose an
update, and the in-memory attributes stay in sync:
$user->increment('login_count'); $user->increment('balance', 25, ['status' => 'vip']); // extra attributes in the same request $user->decrement('balance', 10);
Batch operations (Bulk API)
All batch helpers use the Elasticsearch Bulk API in a single request per chunk — they never loop over single-document calls:
$users = User::bulkCreate([ ['name' => 'A', 'status' => 'active'], ['name' => 'B', 'status' => 'active'], ]); $affected = User::bulkUpdate([ ['id' => 1, 'status' => 'inactive'], ['id' => 2, 'status' => 'inactive'], ]); $affected = User::bulkDelete([1, 2, 3]); $affected = User::destroy([1, 2, 3]); // alias of bulkDelete
The chunk size defaults to bulk.chunk_size (1000) and can be overridden per
call: User::bulkCreate($docs, 500).
Query Builder
use App\Models\User; $users = User::query() ->where('status', 'active') ->where('age', '>', 18) ->orderByDesc('created_at') ->limit(20) ->get();
Every where() appends to the current AND group; every orWhere() starts a new
group. Nested closures reuse the exact same builder:
User::query() ->where('status', 'active') ->where(function ($query) { $query->where('age', '>', 18) ->orWhere('vip', true); }) ->get();
Available where helpers:
where(), orWhere() // '=', '>', '>=', '<', '<=', '!=', 'like', 'not like', 'in', 'not in' whereIn(), whereNotIn() whereNull(), whereNotNull() whereBetween(), whereNotBetween() whereExists(), whereNotExists() whereNested(), orWhereNested()
Execution helpers
// Existence and single-record assertions User::query()->where('status', 'active')->exists(); // bool User::query()->where('email', $email)->sole(); // User, throws when 0 or > 1 match // Metric shortcuts (a size-0 search with a single value aggregation) User::query()->where('status', 'active')->avg('age'); // int|float|null User::query()->sum('price'); User::query()->min('created_at'); User::query()->max('created_at'); // Delete every document matching the query (_delete_by_query, hard delete) User::query()->where('status', 'banned')->deleteByQuery(['refresh' => true]);
sole() throws ModelNotFoundException when nothing matches and
MultipleRecordsFoundException when more than one document matches.
Query DSL
The native Elasticsearch queries are first-class citizens:
Article::query() ->match('title', 'Elasticsearch') ->matchPhrase('excerpt', 'full text phrase') ->multiMatch(['title', 'body'], 'Elasticsearch ORM') ->term('status', 'published') ->terms('category_id', [1, 2, 3]) ->range('created_at', ['gte' => 'now-30d']) ->whereExists('published_at') ->prefix('slug', 'elastic') ->wildcard('title', 'elastic*') ->regexp('slug', 'elas.*') ->fuzzy('title', 'elasticseach', ['fuzziness' => 'AUTO']) ->ids([10, 20, 30]) ->queryString('title:elasticsearch AND status:published', ['fields' => ['title^2']]) ->simpleQueryString('elasticsearch +orm', ['fields' => ['title', 'body']]) ->nested('attributes', fn ($q) => $q->term('attributes.name', 'color')) ->parentId('article', 42) ->hasChild('comment', fn ($q) => $q->term('approved', true)) ->hasParent('article', fn ($q) => $q->term('status', 'published')) ->get();
Bool queries
The Eloquent-style closure form and the explicit bool-occurrence form both
compile to the Elasticsearch bool DSL:
Article::query() ->bool(function ($query) { $query->must(['term' => ['status' => 'published']]); $query->filter(['range' => ['created_at' => ['gte' => 'now-30d']]]); $query->should(['match' => ['title' => 'elasticsearch']]); $query->mustNot(['term' => ['flagged' => true]]); }) ->minimumShouldMatch(1) ->get(); // Explicit occurrences Article::query() ->must(['term' => ['status' => 'published']]) ->filter(['term' => ['tenant_id' => 7]]) ->should(['match' => ['title' => 'orm']]) ->mustNot(['exists' => ['field' => 'deleted_at']]) ->get();
Nested queries
Product::query() ->nested('attributes', function ($query) { $query->term('attributes.name', 'color') ->term('attributes.value', 'red'); }, ['score_mode' => 'avg']) ->get();
Raw DSL
Anything the ORM does not wrap is still reachable, and it can be mixed with the ORM API in the same query. Raw DSL is an advanced API — your input is sent as-is.
Article::query() ->match('title', 'Elasticsearch') ->rawQuery([ 'bool' => [ 'must' => [ ['term' => ['locale' => 'en']], ], ], ]) ->aggregate('by_status', ['terms' => ['field' => 'status']]) ->get(); // Or merge a whole raw request body fragment Article::query()->raw([ 'query' => ['bool' => ['must' => [...]]], 'aggs' => [...], ])->get();
Source filtering
User::query() ->select(['id', 'name', 'email']) // -> _source.includes ->get(); User::query() ->addSelect('avatar') ->selectExcept(['password', 'internal_notes']) // -> _source.excludes ->get();
Sorting
User::query()->orderBy('name')->get(); User::query()->orderByAsc('name')->get(); User::query()->orderByDesc('created_at')->get(); // Multiple fields Article::query()->orderBy([ 'created_at' => 'desc', 'price' => 'asc', ])->get(); // Full Elasticsearch sort DSL Article::query()->sort('price', 'asc', ['missing' => '_last', 'mode' => 'avg'])->get(); Article::query()->orderByDesc('_score')->get();
Operators
| ORM operator | Elasticsearch DSL |
|---|---|
=, == |
term |
!=, <> |
bool.must_not / term |
>, >=, <, <= |
range |
like |
wildcard (% → *) |
not like |
bool.must_not / wildcard |
in |
terms |
not in |
bool.must_not / terms |
Pagination
// Length aware (knows the total) $page = User::query()->where('status', 'active')->paginate(20); $page->items(); // ModelCollection $page->total(); // int $page->currentPage(); // Simple (next / previous only) $page = User::query()->simplePaginate(20); // Cursor pagination built on search_after (deep pagination safe) $page = Article::query() ->orderByDesc('created_at') ->cursorPaginate(20); $page->nextCursor(); // pass it back to continue $next = Article::query() ->orderByDesc('created_at') ->cursorPaginate(20, $page->nextCursor());
Deep pagination primitives:
// search_after Article::query()->orderByDesc('created_at')->searchAfter([/* ... */])->get(); // Point In Time $pit = $connection->request('openPointInTime', ['index' => 'articles', 'keep_alive' => '1m'])['id']; Article::query()->pit($pit)->orderByDesc('created_at')->get(); $connection->request('closePointInTime', ['body' => ['id' => $pit]]);
Elasticsearch's
from + sizepagination is capped (index.max_result_window, 10 000 by default). PrefercursorPaginate()/search_after()/pit()for large result sets.
Aggregations
Shorthand builders:
Product::query() ->termsAgg('categories', 'category_id') ->statsAgg('price_stats', 'price') ->avgAgg('avg_price', 'price') ->sumAgg('total_stock', 'stock') ->minAgg('min_price', 'price') ->maxAgg('max_price', 'price') ->cardinalityAgg('unique_users', 'user_id') ->dateHistogramAgg('sales', 'created_at', 'day') ->histogramAgg('price_histogram', 'price', 50) ->rangeAgg('price_ranges', 'price', [['to' => 50], ['from' => 50, 'to' => 100]]) ->dateRangeAgg('recent', 'created_at', [['from' => 'now-7d/d', 'to' => 'now/d']]) ->filtersAgg('by_status', ['active' => ['term' => ['status' => 'active']]]) ->nestedAgg('attributes', 'attributes') ->reverseNestedAgg('root') ->topHitsAgg('top_products', 3) ->percentilesAgg('price_percentiles', 'price', [50, 95, 99]) ->significantTermsAgg('trending', 'tags') ->filterAgg('published', fn ($q) => $q->term('status', 'published')) ->get();
Complex, nested aggregations:
$result = Article::query() ->aggregate('sales', function ($agg) { $agg->dateHistogram('per_day', 'created_at', 'day'); $agg->avg('avg_price', 'price'); // became a sub-aggregation of 'sales' }) ->get(); $result->aggregation('sales'); // mixed $result->aggregations(); // array<string, mixed>
Highlight
$articles = Article::query() ->match('content', 'Elasticsearch') ->highlight(['title', 'content'], [ 'pre_tags' => ['<em>'], 'post_tags' => ['</em>'], 'fragment_size' => 150, 'number_of_fragments' => 3, 'type' => 'unified', ]) ->get(); foreach ($articles as $article) { $article->highlight('content'); // list<string>|null $article->highlights; // array<string, list<string>> }
Suggest
$result = Article::query() ->suggest([ 'title_suggest' => [ 'text' => 'elasticseach', 'term' => ['field' => 'title'], // 'phrase' => ['field' => 'body'], // 'completion' => ['field' => 'suggest_field'], ], ]) ->search(); $result->suggest();
Geo search
Store::query() ->geoDistance('location', 10, 35.68, 139.76) // field, distance(km), lat, lon ->get(); Store::query() ->geoDistance('location', '2km', ['lat' => 35.68, 'lon' => 139.76]) ->get(); Store::query() ->geoBoundingBox('location', ['top_left' => [139.7, 35.7], 'bottom_right' => [139.8, 35.6]]) ->get(); Store::query() ->geoPolygon('location', [[139.7, 35.7], [139.8, 35.7], [139.8, 35.6], [139.7, 35.6]]) ->get();
Scroll
each() scrolls automatically when no searchAfter()/pit() is configured,
and uses search_after (with a PIT) otherwise. Scroll handles are always
cleared:
User::query()->each(function (User $user): void { // export / migrate / batch process }, chunkSize: 1000); // Break out of the iteration by returning false User::query()->each(function (User $user): bool { return $user->age < 100; // stop at the first user older than 100 });
Bulk
$users = User::bulkCreate([...]); $count = User::bulkUpdate([...]); // each row must contain the primary key $count = User::bulkDelete([...]);
Individual failures are surfaced through EsOrm\Exceptions\BulkException
(->failures()).
MGET & Multi Search
// Multi get (single request) $users = User::mget([1, 2, 3]); // Multi search: one _msearch request for many queries use EsOrm\Query\Search; $results = Search::multi([ 'users' => User::query()->match('name', 'Tom'), 'articles' => Article::query()->term('status', 'published'), ]); $results['users']->hits(); // ModelCollection $results['users']->total(); // int
Casts
protected array $casts = [ 'age' => 'integer', 'price' => 'float', 'active' => 'boolean', 'name' => 'string', 'metadata' => 'array', 'options' => 'json', 'created_at' => 'datetime', 'birthday' => 'date', 'profile' => 'object', ];
Supported cast names: integer/int, float/double/decimal, boolean/bool,
string, array, json, object/map, datetime/date_time/timestamp, date.
Accessors & Mutators
use EsOrm\Model\Attribute; final class User extends Model { protected function name(): Attribute { return Attribute::make( get: fn ($value) => ucfirst((string) $value), set: fn ($value) => strtolower((string) $value), ); } }
The getter also receives the whole attribute array as its second argument, so
cross-field accessors are possible. The method must declare an Attribute
return type to be recognised.
Fillable / Guarded
protected array $fillable = ['name', 'email']; protected array $guarded = ['*']; // default: everything is guarded unless fillable $user->fill(['name' => 'Tom', 'role' => 'admin']); // 'role' is ignored $user->forceFill(['role' => 'admin']); // bypasses the guard
Scopes
Local scopes are plain methods prefixed with scope:
final class User extends Model { public function scopeActive($query) { return $query->term('status', 'active'); } } User::active()->get(); // via __callStatic -> query() User::query()->active()->get();
Object scopes implement EsOrm\Scope\Scope; global scopes are registered on the
model and applied to every query:
use EsOrm\Scope\Scope; Model::addGlobalScope(new class implements Scope { public function apply(\EsOrm\Contracts\QueryBuilderInterface $builder): void { $builder->term('tenant_id', 7); } });
Soft deletes
use EsOrm\Model\Model; use EsOrm\Model\SoftDeletes; final class User extends Model { use SoftDeletes; protected string $index = 'users'; protected array $casts = ['deleted_at' => 'datetime']; } $user->delete(); // sets deleted_at, keeps the document $user->trashed(); // bool $user->restore(); // clears deleted_at $user->forceDelete(); // removes the document User::query()->get(); // excludes trashed User::withTrashed()->get(); // includes trashed User::onlyTrashed()->get(); // only trashed
Events & Observers
Model events are dispatched through EsOrm\Events\EventDispatcher:
creating, created, saving, saved, updating, updated, deleting,
deleted, restoring, restored, forceDeleting, forceDeleted.
use EsOrm\Model\Model; Model::eventDispatcher()->listen('created', function (Model $model): void { // ... }); // A listener returning false aborts the operation (e.g. saving/creating/deleting) // Observers final class UserObserver { public function created(User $user): void {} public function updated(User $user): void {} public function deleted(User $user): void {} } User::observe(UserObserver::class);
A Laravel application may replace the dispatcher with a bridge to the framework
event system via Model::setEventDispatcher().
Relations
Relations are a batch-aware convenience layer (not SQL joins) designed so eager loading never triggers an N+1:
final class Article extends Model { protected string $index = 'articles'; public function author(): \EsOrm\Relation\BelongsTo { return $this->belongsTo(Author::class, 'author_id'); } } final class Author extends Model { protected string $index = 'authors'; public function articles(): \EsOrm\Relation\HasMany { return $this->hasMany(Article::class, 'author_id'); } } // Eager load in a single batch query $articles = Article::with('author')->get(); // Lazy eager load on loaded models $article->load('author'); $article->loadMissing('author');
Available: HasOne, HasMany, BelongsTo. Nested eager loads are supported via
dot notation (Article::with('author.company')).
Mapping
A model declares its mapping; convert it to an index body with
mappingDefinition() or hand the raw array to the IndexManager.
final class Article extends Model { protected string $index = 'articles'; protected function mapping(): array { return [ 'properties' => [ 'title' => [ 'type' => 'text', 'fields' => ['keyword' => ['type' => 'keyword']], ], 'status' => ['type' => 'keyword'], 'created_at' => ['type' => 'date'], 'price' => ['type' => 'double'], ], ]; } } $article->mappingDefinition(); // EsOrm\Contracts\MappingInterface
Or build a mapping fluently:
use EsOrm\Mapping\Mapping; $mapping = Mapping::create('articles') ->text('title', ['fields' => ['keyword' => ['type' => 'keyword']]]) ->keyword('status') ->date('created_at') ->double('price') ->nested('attributes') ->geoPoint('location');
Index management
use EsOrm\Index\IndexManager; $indices = IndexManager::on(Model::connectionManager()); // or Es::index() in Laravel $indices->createIndex('articles', $mapping, ['number_of_replicas' => 0]); $indices->indexExists('articles'); $indices->getIndex('articles'); $indices->getMapping('articles'); $indices->putMapping('articles', $extraMapping); $indices->refresh('articles'); $indices->flush('articles'); $indices->forceMerge('articles', ['max_num_segments' => 1]); $indices->close('articles'); $indices->open('articles'); $indices->deleteIndex('articles');
Alias management
use EsOrm\Index\AliasManager; $aliases = AliasManager::on(Model::connectionManager()); $aliases->createAlias('articles_v1', 'articles'); $aliases->switchAlias('articles', 'articles_v2'); // atomic, auto-detects current index $aliases->deleteAlias('articles_v2', 'articles'); $aliases->getAlias('articles');
Reindex
use EsOrm\Index\ReindexManager; $reindex = ReindexManager::on(Model::connectionManager()); $reindex->reindexTo('articles_v1', 'articles_v2', [ 'query' => ['term' => ['status' => 'published']], 'batch_size' => 1000, 'script' => ['source' => 'ctx._source.migrated = true'], 'conflicts' => 'proceed', 'refresh' => true, ]); // Zero-downtime upgrade: $indices->createIndex('articles_v2', $newMapping); $reindex->reindexTo('articles_v1', 'articles_v2'); $aliases->switchAlias('articles', 'articles_v2');
Index migrations
<?php declare(strict_types=1); namespace Database\EsMigrations; use EsOrm\Migration\IndexMigration; use EsOrm\Mapping\Mapping; final class CreateArticlesIndex extends IndexMigration { public function up(): void { $this->createIndex('articles', Mapping::create('articles') ->text('title') ->keyword('status') ->date('created_at')); } public function down(): void { $this->deleteIndex('articles'); } }
Run them through the framework-agnostic runner:
use EsOrm\Migration\Migrator; $migrator = new Migrator($indices, 'database/es-migrations'); $migrator->migrate(); // apply pending migrations $migrator->rollback(1); // roll back the last batch $migrator->status(); // list the discovered migrations and their state
Or via Artisan (Laravel): php artisan es:migrate,
php artisan es:migrate:rollback, php artisan es:migrate:status.
Caching
Query caching is disabled by default. Enable it with an opt-in per query and
a configurable driver implementing EsOrm\Contracts\CacheStoreInterface:
Article::query() ->where('status', 'active') ->cache(60) // seconds, optional custom key as 2nd argument ->get();
Debugging
$query = User::query()->where('status', 'active'); $query->toDsl(); // array<string, mixed> $query->toJson(); // string $query->dump(); // pretty print (VarDumper aware) $query->dd(); // dump and die
The connection records every request (HTTP method, endpoint, index, body,
duration and errors) into EsOrm\Support\QueryLog when logging.enabled is on.
Error handling
| Exception | Thrown when |
|---|---|
ConnectionException |
transport failure, server 5xx, retry exhaustion |
QueryException |
a non-index / non-bulk request failed |
IndexException |
an index-level request failed |
BulkException |
a bulk request failed (->failures() exposes per-item errors) |
MappingException |
an invalid mapping or unsupported cast type |
InvalidQueryException |
an invalid builder call / unknown operator |
ModelNotFoundException |
findOrFail() / firstOrFail() found nothing |
ValidationException |
invalid input rejected before hitting Elasticsearch |
SearchException, PaginationException, DslException, ReindexException |
other specific failures |
All extend EsOrm\Exceptions\EsOrmException.
Laravel integration
The core has no Laravel dependency; the integration is an adapter registered by package discovery.
use EsOrm\Laravel\Facades\Es; Es::connection(); // default connection Es::connection('logs'); // named connection Es::index(); // IndexManager Es::alias(); // AliasManager Es::reindex(); // ReindexManager Es::migrator(); // Migrator Es::search([...]); // raw search
Publish the config:
php artisan vendor:publish --tag=es_orm-config
AppServiceProvider.php use EsOrm\Contracts\ConnectionManagerInterface; public function register(): void { $this->app->make(ConnectionManagerInterface::class); }
Models work unchanged inside a Laravel project.
Docker & tooling
make install # composer install make elasticsearch # start Elasticsearch (+ Kibana) via docker compose make test # PHPUnit make analyse # PHPStan make cs # coding standard dry-run make fix # apply coding standard fixes make clean # remove caches / artefacts
docker-compose.yml provides Elasticsearch 8.15 and Kibana (profile kibana).
Testing
# Unit tests (no Elasticsearch needed, everything is mocked) php vendor/bin/phpunit --testsuite Unit # Integration tests (require a running Elasticsearch) ES_INTEGRATION=true php vendor/bin/phpunit --testsuite Integration
FAQ
Do I need Laravel to use this package? No. The core ORM is framework agnostic. Laravel is an optional adapter.
Does it replace the Elasticsearch Query DSL?
No. The DSL is fully reachable, and rawQuery() / raw() let you pass any DSL
the ORM does not wrap.
How do I avoid deep pagination problems?
Use cursorPaginate(), searchAfter() or pit() instead of large offset()
values.
Are bulk operations really batched?
Yes — every bulkCreate()/bulkUpdate()/bulkDelete() call issues Bulk API
requests, one per chunk.
How do I change the default page size?
Set pagination.default_size in config/es_orm.php, or pass it to
paginate().
Is rawQuery() safe?
It is an advanced API: input is forwarded to Elasticsearch as-is. Use it only
with trusted input; normal user input should go through the DSL builder.
Architecture
Model → Query Builder → DSL → Connection → Elasticsearch Client
- Model is a thin, typed façade: it never performs HTTP.
- Query Builder accumulates DSL value objects and compiles a single request body.
- DSL value objects (
MatchQuery,TermsQuery,NestedQuery, aggregations, sorts, …) each exposetoArray(). - Connection owns transport concerns: retry, logging, exception mapping.
- Client is a thin adapter over the official Elasticsearch client.
- Laravel integration is a separate adapter on top.
This separation keeps the API stable and lets new Elasticsearch features be added without breaking existing code.
License
MIT — see LICENSE.