# ElasticLens > ElasticLens creates and syncs searchable Elasticsearch indexes from your Laravel SQL models. Define field mappings, embed relationships as nested objects, and search with the full power of the Elasticsearch query builder - all returning your original Laravel models. - Package: pdphilip/elasticlens v4 for Laravel 10, 11 & 12 with Elasticsearch 8.x - Requires: pdphilip/elasticsearch ^5 - Docs: https://elasticlens.pdphilip.com - GitHub: https://github.com/pdphilip/elasticlens - Last updated: 2026-02-22 > ElasticLens uses the Laravel-Elasticsearch package (https://elasticsearch.pdphilip.com) as its query engine. All Elasticsearch query methods (search, geo, nested, aggregations, highlighting) are available through `viaIndex()`. --- ## ElasticLens The convenience of Scout. The full power of Elasticsearch. Complete control of your models. ```php // Starts just like Scout - add a trait, search your models. User::search('loves espressos'); ``` ```php // Except you're not limited to basic text search. User::viaIndex() ->searchPhrase('ice bathing') ->where('status', 'active') ->whereNestedObject('logs', function ($query) { $query->where('logs.country', 'Norway'); }) ->orderByDesc('created_at') ->paginate(10); ``` ```php // Geo queries. Fuzzy matching. Regex. Aggregations. On your Laravel models. User::viaIndex() ->filterGeoPoint('home.location', '5km', [40.7128, -74.0060]) ->orderByGeo('home.location', [40.7128, -74.0060]) ->get(); ``` Scout gives you a search box behind a black box. ElasticLens gives you a search engine you can open up. Every index is a real Eloquent model you can query, inspect, and control directly. You define the field mappings. You define the Elasticsearch schema. You see exactly what's indexed and how. No magic, no guessing, no driver abstractions between you and your data. --- ## The Query Boundary `viaIndex()` is the gateway. Everything you chain after it is running against Elasticsearch's full query builder. Results come back as your Laravel models. [See diagram in the online documentation] ```php User::viaIndex() // Gateway: enter Elasticsearch scope ->searchTerm('max') // ← full-text search ->where('state', 'active') // ← familiar Eloquent syntax ->whereNestedObject('logs', // ← search across embedded relations fn ($q) => $q->where('logs.country', 'Norway') ) ->orderByDesc('created_at') // ← ordering, pagination, aggregations ->paginate(10); // Returns: Paginator of User models ``` Everything between `viaIndex()` and `get()`/`paginate()` is the Elasticsearch query builder - the same one from the [Laravel-Elasticsearch](https://elasticsearch.pdphilip.com) package. Full-text search, geo queries, fuzzy matching, regex, nested objects, aggregations, highlighting - all of it. [See the full search capabilities →](https://elasticlens.pdphilip.com/full-text-search) --- ## Embed Relationships Into Your Index This is where it gets interesting. Flatten your relational data into Elasticsearch and search across it as nested objects. [See diagram in the online documentation] Define what gets embedded in your index model: ```php class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('name'); $field->text('email'); $field->embedsMany('profiles', Profile::class)->embedMap(function ($field) { $field->text('bio'); $field->array('tags'); }); $field->embedsBelongTo('company', Company::class)->embedMap(function ($field) { $field->text('name'); $field->text('industry'); }); $field->embedsMany('logs', UserLog::class, null, null, function ($query) { $query->orderBy('created_at', 'desc')->limit(10); })->embedMap(function ($field) { $field->text('action'); }); }); } } ``` Then search across all of it: ```php User::viaIndex()->whereNestedObject('profiles', function ($query) { $query->where('profiles.bio', 'like', '%elasticsearch%'); })->get(); ``` The related models are observed too. Update a `Profile` and the parent `IndexedUser` rebuilds automatically. [See the full Embedded Relations guide →](https://elasticlens.pdphilip.com/embedded-relations) --- ## Features - [**Auto-sync**](https://elasticlens.pdphilip.com/getting-started) - Every create, update, and delete on your base model is reflected in the index automatically - [**Embedded relations**](https://elasticlens.pdphilip.com/embedded-relations) - Flatten hasMany, hasOne, belongsTo into searchable nested objects - [**Full-text search**](https://elasticlens.pdphilip.com/full-text-search) - Term, phrase, fuzzy, regex, geo, nested object queries - all returning your Laravel models - [**Index migrations**](https://elasticlens.pdphilip.com/index-model-migrations) - Define your Elasticsearch mapping with a Blueprint, just like database migrations - [**Conditional indexing**](https://elasticlens.pdphilip.com/conditional-indexing) - Control which records get indexed with `excludeIndex()` - [**Soft delete support**](https://elasticlens.pdphilip.com/soft-deletes) - Configure whether soft-deleted records keep their index - [**CLI tools**](https://elasticlens.pdphilip.com/artisan-cli-tools) - Status, health, build, migrate, and make commands --- ## Requirements | | Version | |---|---| | PHP | 8.2+ | | Laravel | 10 / 11 / 12 | | Elasticsearch | 8.x | [ Get Started → ](https://elasticlens.pdphilip.com/getting-started) --- ## Getting Started Install ElasticLens and connect your first model to Elasticsearch in under a minute. > **Note:** > **ElasticLens is a separate package from Laravel-Elasticsearch**. Laravel-Elasticsearch is Eloquent for Elasticsearch. ElasticLens is a package that uses Laravel-Elasticsearch to create and sync a searchable index of your SQL models. ## Installation - Laravel 10/11/12 - Elasticsearch 8.x Before you start, you'll need to configure your Elasticsearch connection for Laravel. See the [Laravel-Elasticsearch Getting Started Guide](https://elasticsearch.pdphilip.com/getting-started/) for full setup instructions. ```bash composer require pdphilip/elasticlens ``` Publish the config file and run the migrations with: ```bash php artisan lens:install ``` Run the migrations to create the index build and migration logs indexes: ```bash php artisan migrate ``` ## Optional Configuration `lens:install` will publish the config file to `config/elasticlens.php` and create the migration files build and migration logs. You can customize the configuration in `config/elasticlens.php` ```php // config/elasticlens.php return [ 'database' => 'elasticsearch', 'queue' => null, // Set queue to use for dispatching index builds 'index_soft_deletes' => false, // See: Soft Delete Support 'watchers' => [ // \App\Models\Profile::class => [ // \App\Models\Indexes\IndexedUser::class, // ], ], 'index_build_state' => [ 'enabled' => true, 'log_trim' => 2, ], 'index_migration_logs' => [ 'enabled' => true, ], 'namespaces' => [ 'App\Models' => 'App\Models\Indexes', ], 'index_paths' => [ 'app/Models/Indexes/' => 'App\Models\Indexes', ], ]; ``` ## How it works The **Index Model** acts as a separate Elasticsearch model managed by ElasticLens, yet you retain full control over it, just like any other Laravel model. In addition to working directly with the **Index Model**, ElasticLens offers tools for - Full-text searching of your **Base Models** using the full feature set of the Larevel-Elasticsearch package - Mapping fields (with embedded relationships) during the build process - Define **Index Model** migrations. - [Conditional indexing](https://elasticlens.pdphilip.com/conditional-indexing) to exclude specific records - [Soft delete support](https://elasticlens.pdphilip.com/soft-deletes) to keep or remove index records on soft delete - CLI tools to view sync status and manage your **Index Models** For Example, a base **User** Model will sync with an Elasticsearch **IndexedUser** Model that provides all the features from **Laravel-Elasticsearch** to search your base **User** Models --- ## What's New in V4 ElasticLens v4 brings a cleaner search API, conditional indexing, and soft delete support. ## viaIndex Scope Flag The biggest change in v4: `viaIndex()` now returns **Base Models** by default. Previously, `viaIndex()->get()` returned **Index Models** and you had to call `getBase()` or `paginateBase()` to get base models. In v4, `get()` and `paginate()` return base models automatically when called through `viaIndex()`: ```php // v3 - had to use getBase() User::viaIndex()->searchTerm('david')->getBase(); // v4 - get() returns base models through viaIndex() User::viaIndex()->searchTerm('david')->get(); ``` For explicit control, new methods are available: - `getIndex()` / `paginateIndex()` - Always return **Index Models** - `getBase()` / `paginateBase()` - Always return **Base Models** (still available, now just aliases) ## Conditional Indexing New `excludeIndex()` method on your **Base Model** lets you skip specific records during indexing: ```php class User extends Model { use Indexable; public function excludeIndex(): bool { return $this->status === 'banned'; } } ``` Excluded records are tracked as **skipped** in build state and health checks. See [Conditional Indexing](https://elasticlens.pdphilip.com/conditional-indexing) for details. ## Soft Delete Support ElasticLens now handles soft deletes intelligently: - **Global config:** `index_soft_deletes` in `config/elasticlens.php` - **Per-model override:** `$indexSoftDeletes` property on your **Index Model** - **Restore detection:** Automatically re-indexes when a model is restored ```php // config/elasticlens.php 'index_soft_deletes' => true, // Keep index records when soft deleting // Or per-model class IndexedUser extends IndexModel { protected ?bool $indexSoftDeletes = true; } ``` See [Soft Delete Support](https://elasticlens.pdphilip.com/soft-deletes) for the full configuration guide. ## LensBuilder Under the hood, ElasticLens now uses a dedicated `LensBuilder` that extends the Elasticsearch Builder. This powers the viaIndex scope flag and provides a cleaner architecture for the search API. The `IndexModel::query()` method now returns a `LensBuilder` instance. --- ## Upgrading from V3 Upgrading from ElasticLens v3 to v4 is straightforward. Most changes are additive; the core breaking change is how `viaIndex()` returns results. ## Update Composer ```bash composer require pdphilip/elasticlens:^4.0 ``` ## Breaking Change: viaIndex() Return Type In v3, `viaIndex()->get()` returned **Index Models**. In v4, it returns **Base Models**. ### If you used `getBase()` / `paginateBase()` These still work. They're now aliases for the default behavior through `viaIndex()`: ```php // v3 (still works in v4, just redundant) User::viaIndex()->searchTerm('david')->getBase(); // v4 equivalent User::viaIndex()->searchTerm('david')->get(); ``` No action required; your existing `getBase()` / `paginateBase()` calls continue to work. ### If you used `get()` / `paginate()` directly If you relied on `viaIndex()->get()` returning index models, switch to the new explicit methods: ```php // v3 - returned IndexedUser models User::viaIndex()->searchTerm('david')->get(); // v4 - use getIndex() for index models User::viaIndex()->searchTerm('david')->getIndex(); ``` Same for pagination: ```php // v3 - returned paginator of IndexedUser User::viaIndex()->searchTerm('david')->paginate(10); // v4 - use paginateIndex() for index models User::viaIndex()->searchTerm('david')->paginateIndex(10); ``` ### If you used `asBase()` on collections Still works: ```php // v3 (still works) User::viaIndex()->searchTerm('david')->getIndex()->asBase(); // v4 - simpler User::viaIndex()->searchTerm('david')->get(); ``` ## New Features (No Action Required) ### Conditional Indexing New opt-in feature. Override `excludeIndex()` on your base model to skip records: ```php public function excludeIndex(): bool { return $this->status === 'banned'; } ``` ### Soft Delete Support New opt-in feature. Configure in `config/elasticlens.php`: ```php 'index_soft_deletes' => true, ``` Or per-model on your **Index Model**: ```php protected ?bool $indexSoftDeletes = true; ``` ### New Config Option The config file has a new `index_soft_deletes` key. Re-publish to get it: ```bash php artisan vendor:publish --tag=elasticlens-config ``` Or add it manually: ```php // config/elasticlens.php 'index_soft_deletes' => false, ``` --- --- ## Indexing your Base-Model Instant config. Add the `Indexable` trait to your **Base Model** then set your **Index Model** and you're good to go. --- ## Setup ### 1. Add the `Indexable` Trait to Your Base-Model ```php //App\Models\Profile.php + use PDPhilip\ElasticLens\Indexable; class Profile extends Model { + use Indexable; ``` ### 2 (a) Create an Index-Model for your Base-Model - ElasticLens expects the **Index Model** to be named as `Indexed` + `BaseModelName` and located in the `App\Models\Indexes` directory. ```php //App\Models\Indexes\IndexedProfile.php namespace App\Models\Indexes; use PDPhilip\ElasticLens\IndexModel; class IndexedProfile extends IndexModel{} ``` ### 2 (b) Or create with artisan ```bash php artisan lens:make Profile ``` Generates a new index for the `Profile` model. - That's it! Your `Profile` model will now automatically sync with the `IndexedProfile` model whenever changes occur. You can search your User model effortlessly, like: ```php Profile::viaIndex()->searchTerm('running')->orSearchTerm('swimming')->get(); ``` ## Build Indexes for existing data ```bash php artisan lens:build Profile ``` Build/Rebuilds all the `IndexedProfile` records for the `Profile` model. --- ## Index-model field mapping Define your index-model's field mapping and embedded relationships to fine-tune your indexed data ## Defining a field Map By default, the **Index Model** will be built with all the fields it finds from the **Base Model** during synchronisation. However, you can customize this by defining a `fieldMap()` method in your **Index Model**. ```php + use PDPhilip\ElasticLens\Builder\IndexBuilder; + use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; + public function fieldMap(): IndexBuilder + { + return IndexBuilder::map(User::class, function (IndexField $field) { + $field->text('first_name'); + $field->text('last_name'); + $field->text('email'); + $field->bool('is_active'); //See attributes as fields + $field->type('state', UserState::class); //Maps enum + $field->text('created_at'); + $field->text('updated_at'); + }); + } ``` > **Note:** > If the `fieldMap()` is defined then it will **only** build the fields defined within. > > The `id` can be excluded as the value of `$user->id` will correspond to `$indexedUser->id`. > > When mapping enums, ensure that you also cast them in the **Index Model**. > > If a value is not found during the build process, it will be stored as `null`. ## Attributes as fields If your **Base Model** has attributes (calculated values) that you would like to have searchable, you can define them in the `fieldMap()` as if they were a regular field. > For example, `$field->bool('is_active')` could be derived from a custom attribute > in the **Base Model**: ```php //App\Models\User.php // @property-read bool is_active public function getIsActiveAttribute(): bool { return $this->updated_at >= Carbon::now()->modify('-30 days'); } ``` Be mindful that these values are stored in Elasticsearch at their current state during synchronisation. --- ## Relationships as embedded fields The stand-out feature of ElasticLens is the ability to embed relationships within your **Index Model**. This allows you to create a more structured and searchable index beyond the flat structure of your **Base Model**. As an illustration, consider the following relationships around the `User` model which will be the base model for our `IndexedUser` model. [See diagram in the online documentation] ### EmbedsMany EX: `User` has many `Profiles` ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->bool('is_active'); $field->type('type', UserType::class); $field->type('state', UserState::class); $field->text('created_at'); $field->text('updated_at'); + $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { + $field->text('profile_name'); + $field->text('about'); + $field->array('profile_tags'); + }); }); } ``` ### EmbedsOne EX: `Profile` has one `ProfileStatus` ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->bool('is_active'); $field->type('type', UserType::class); $field->type('state', UserState::class); $field->text('created_at'); $field->text('updated_at'); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('profile_tags'); + $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { + $field->text('id'); + $field->text('status'); + }); }); }); } ``` ### EmbedsBelongTo EX: `User` belongs to an `Account` ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->bool('is_active'); $field->type('type', UserType::class); $field->type('state', UserState::class); $field->text('created_at'); $field->text('updated_at'); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('profile_tags'); $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { $field->text('id'); $field->text('status'); }); }); + $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { + $field->text('name'); + $field->text('url'); + }); }); } ``` ### Embedding without observing/watching EX: `User` belongs to a `Country` and you don't need to observe the `Country` model to trigger a rebuild of the `User` model. ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->bool('is_active'); $field->type('type', UserType::class); $field->type('state', UserState::class); $field->text('created_at'); $field->text('updated_at'); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('profile_tags'); $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { $field->text('id'); $field->text('status'); }); }); $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { $field->text('name'); $field->text('url'); }); + $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { + $field->text('country_code'); + $field->text('name'); + $field->text('currency'); + })->dontObserve(); // Don't observe changes in the country model }); } ``` ### EmbedsMany with a query EX: `User` has Many `UserLogs` and you only want to embed the last 10: ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->bool('is_active'); $field->type('type', UserType::class); $field->type('state', UserState::class); $field->text('created_at'); $field->text('updated_at'); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('profile_tags'); $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { $field->text('id'); $field->text('status'); }); }); $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { $field->text('name'); $field->text('url'); }); $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { $field->text('country_code'); $field->text('name'); $field->text('currency'); })->dontObserve(); // Don't observe changes in the country model + $field->embedsMany('logs', UserLog::class, null, null, function ($query) { + $query->orderBy('created_at', 'desc')->limit(10); // Limit the logs to the 10 most recent + })->embedMap(function (IndexField $field) { + $field->text('title'); + $field->text('ip'); + $field->array('log_data'); + }); }); } ``` ## `IndexField` Methods ### Field types: - `text($field)` - `integer($field)` - `array($field)` - `bool($field)` - `type($field, $type)` - Set own type (like Enums) - `embedsMany($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` - `embedsBelongTo($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` - `embedsOne($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` > **Note:** > For embeds the `$whereRelatedField`, `$equalsLocalField`, `$query` parameters are optional. > > `$whereRelatedField` is the `foreignKey` & `$equalsLocalField` is the `localKey` and they will be inferred from the relationship if not provided. > > `$query` is a closure that allows you to customize the query for the related model. ### Embedded field type methods: - `embedMap(function (IndexField $field) {})` - Define the mapping for the embedded relationship - `dontObserve()` - Don't observe changes in the `$relatedModelClass` --- --- ## Index-model migrations Define your index-model's `migrationMap()` to take control of the index structure. ## Define the `migrationMap()` Elasticsearch automatically indexes new fields it encounters, but it may not always index them in the way you need. To ensure the index is structured correctly, you can define a `migrationMap()` in your Index Model. Since the **Index Model** utilizes the Laravel-Elasticsearch package, use [Index Blueprint](https://elasticsearch.pdphilip.com/schema/index-blueprint/) to define your index's field map in the `migrationMap()` method. ```php use PDPhilip\Elasticsearch\Schema\Blueprint; class IndexedUser extends IndexModel { //...... public function migrationMap(): callable { return function (Blueprint $index) { $index->text('name'); $index->keyword('first_name'); $index->text('first_name'); $index->keyword('last_name'); $index->text('last_name'); $index->keyword('email'); $index->text('email'); $index->text('avatar')->indexField(false); $index->keyword('type'); $index->text('type'); $index->keyword('state'); $index->text('state'); //...etc }; } ``` ## Run the Migration ```bash php artisan lens:migrate User ``` This command will delete the existing index and records, run the migration, and rebuild all records. --- --- ## Embedded Relations The real power of ElasticLens: embed your relationships into Elasticsearch and search across everything at once. ## The Scenario Consider a typical User model with related data spread across multiple tables: [See diagram in the online documentation] To search "find all active users who work in tech companies and have a profile mentioning elasticsearch", you'd need to JOIN across 3 tables, add WHERE clauses, and still wouldn't have full-text search. ElasticLens solves this by flattening everything into a single searchable document at index time. --- ## Building the fieldMap Start with the **Index Model** and define what gets indexed. We'll build it up step by step. ### Base fields ```php use PDPhilip\ElasticLens\Builder\IndexBuilder; use PDPhilip\ElasticLens\Builder\IndexField; class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->type('state', UserState::class); }); } } ``` This indexes the User's own fields. Now let's embed the relationships. ### embedsMany - User has many Profiles ```php return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->type('state', UserState::class); + $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { + $field->text('profile_name'); + $field->text('about'); + $field->array('tags'); + }); }); ``` Each User's profiles are now embedded as nested objects in the Elasticsearch document. The `Profile` model is automatically observed - update a profile and the parent IndexedUser rebuilds. ### embedsOne - nested within an embed Profile has one ProfileStatus. You can nest embeds inside embeds: ```php $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('tags'); + $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { + $field->text('status'); + }); }); ``` When a `ProfileStatus` changes, ElasticLens traces up: ProfileStatus → Profile → User → rebuilds IndexedUser. ### embedsBelongTo - User belongs to Account ```php return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->type('state', UserState::class); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('tags'); $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { $field->text('status'); }); }); + $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { + $field->text('name'); + $field->text('industry'); + }); }); ``` ### dontObserve - static reference data For data that rarely changes (like countries), skip the observer to avoid unnecessary rebuilds: ```php + $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { + $field->text('name'); + $field->text('currency'); + })->dontObserve(); ``` ### Query-limited embeds Embed only the 10 most recent logs instead of all of them: ```php + $field->embedsMany('logs', UserLog::class, null, null, function ($query) { + $query->orderBy('created_at', 'desc')->limit(10); + })->embedMap(function (IndexField $field) { + $field->text('action'); + $field->text('ip'); + }); ``` --- ## The Complete fieldMap ```php class IndexedUser extends IndexModel { protected $baseModel = User::class; public function fieldMap(): IndexBuilder { return IndexBuilder::map(User::class, function (IndexField $field) { $field->text('first_name'); $field->text('last_name'); $field->text('email'); $field->type('state', UserState::class); $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { $field->text('profile_name'); $field->text('about'); $field->array('tags'); $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { $field->text('status'); }); }); $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { $field->text('name'); $field->text('industry'); }); $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { $field->text('name'); $field->text('currency'); })->dontObserve(); $field->embedsMany('logs', UserLog::class, null, null, function ($query) { $query->orderBy('created_at', 'desc')->limit(10); })->embedMap(function (IndexField $field) { $field->text('action'); $field->text('ip'); }); }); } } ``` --- ## What Gets Stored When User #1 is indexed, ElasticLens builds a single Elasticsearch document: ```json { "id": 1, "first_name": "Max", "last_name": "Philip", "email": "max@example.com", "state": "active", "profiles": [ { "profile_name": "dev", "about": "Loves Elasticsearch and Laravel", "tags": ["php", "laravel", "elasticsearch"], "status": { "status": "available" } }, { "profile_name": "music", "about": "Jazz piano and vinyl collecting", "tags": ["jazz", "piano"], "status": { "status": "active" } } ], "account": { "name": "Acme Inc", "industry": "Technology" }, "country": { "name": "South Africa", "currency": "ZAR" }, "logs": [ { "action": "login", "ip": "127.0.0.1" }, { "action": "profile_update", "ip": "127.0.0.1" } ] } ``` Six SQL tables, zero JOINs, one searchable document. --- ## Searching Embedded Fields Now you can search across everything: ### Nested object queries ```php // Find users with profiles mentioning "elasticsearch" User::viaIndex()->whereNestedObject('profiles', function ($query) { $query->where('profiles.about', 'like', '%elasticsearch%'); })->get(); ``` ### Combine with other queries ```php // Active users at tech companies with recent login activity from Norway User::viaIndex() ->where('state', 'active') ->where('account.industry', 'Technology') ->whereNestedObject('logs', function ($query) { $query->where('logs.action', 'login') ->where('logs.ip', '!=', '127.0.0.1'); }) ->get(); ``` ### Full-text search across embedded data ```php // Search term across ALL fields - base and embedded User::viaIndex()->searchTerm('elasticsearch')->get(); ``` The `searchTerm` query searches across all indexed fields, including embedded ones. A match in `profiles.about` will surface the parent User. --- ## The Observer Chain When embedded models change, ElasticLens automatically traces back to the base model and rebuilds. [See diagram in the online documentation] This happens automatically for any embedded model. No manual intervention needed: - **Save a Profile** → IndexedUser for that profile's user rebuilds - **Delete a UserLog** → IndexedUser for that log's user rebuilds - **Update a ProfileStatus** → traces through Profile → User → rebuilds Models marked with `dontObserve()` (like Country in our example) skip this chain. Changes to country data require a manual rebuild via `lens:build`. --- ## Advanced Patterns ### Independent watchers with `HasWatcher` If you want to observe a related model independently (outside of the fieldMap chain), use the `HasWatcher` trait: ```php // App\Models\ProfileStatus.php use PDPhilip\ElasticLens\HasWatcher; class ProfileStatus extends Eloquent { use HasWatcher; } ``` Then register it in `config/elasticlens.php`: ```php 'watchers' => [ \App\Models\ProfileStatus::class => [ \App\Models\Indexes\IndexedUser::class, ], ], ``` See [Model Observers](https://elasticlens.pdphilip.com/model-observers) for the full configuration reference. ### Index migrations for embedded fields When embedding relationships, define nested mappings in your `migrationMap()`: ```php public function migrationMap(): callable { return function (Blueprint $index) { $index->text('first_name'); $index->keyword('first_name'); $index->text('email'); $index->keyword('state'); $index->nested('profiles'); $index->nested('logs'); }; } ``` See [Index Migrations](https://elasticlens.pdphilip.com/index-model-migrations) for the full migration reference. --- ## Summary | Embed Type | Method | Observes by default | |---|---|---| | Has Many | `embedsMany()` | Yes | | Has One | `embedsOne()` | Yes | | Belongs To | `embedsBelongTo()` | Yes | | Any + skip observer | `.dontObserve()` | No | | Any + query constraint | 5th parameter closure | Yes | For the full API reference, see [Field Mapping](https://elasticlens.pdphilip.com/field-mapping). --- ## Full-text Search This isn't a search driver. It's the full Elasticsearch query builder, returning your Laravel models. `viaIndex()` gives you access to every query method from the [Laravel-Elasticsearch](https://elasticsearch.pdphilip.com) package. Full-text search, geo queries, fuzzy matching, regex, nested objects, aggregations, highlighting - the works. ## Quick Search The `search()` convenience method runs a phrase prefix search across all fields: ```php User::search('loves espressos'); ``` > Returns a Collection of `User` models. Good for search bars and quick lookups. ## viaIndex() For everything else, use `viaIndex()` to enter the Elasticsearch query builder: ```php User::viaIndex()->searchTerm('suvi')->get(); // Collection of User models User::viaIndex()->where('state', 'active')->paginate(); // Paginator of User models User::viaIndex()->searchPhrase('ice bathing')->first(); // User model or null ``` Results come back as your **Base Models** by default. You can also request **Index Models** directly: | Method | Returns | |---|---| | `get()` / `paginate()` / `first()` | Base models (User) | | `getBase()` / `paginateBase()` | Base models (explicit) | | `getIndex()` / `paginateIndex()` | Index models (IndexedUser) | Use `getIndex()` when you need access to embedded fields or search highlights on the index model itself. Convert back anytime with `->asBase()`. --- ## Full-text Search Search across all indexed fields with multi-match queries. ```php // Term search - finds "nara" across all fields User::viaIndex()->searchTerm('nara')->get(); // Phrase search - exact word sequence User::viaIndex()->searchPhrase('ice bathing')->get(); // Fuzzy search - handles typos User::viaIndex()->searchFuzzy('quikc brwn foks')->get(); // Phrase prefix - autocomplete-style matching User::viaIndex()->searchPhrasePrefix('loves espr')->get(); ``` Boost specific fields to control relevance: ```php User::viaIndex()->searchTerm('suvi', ['first_name^3', 'last_name^2', 'bio'])->first(); ``` Also available: `searchBoolPrefix()`, `searchTermMost()`, `searchTermCross()`, `searchQueryString()` [Full reference: Search Queries →](https://elasticsearch.pdphilip.com/eloquent/search-queries/) --- ## Field-level Queries Target specific fields with Elasticsearch-native query types. ```php // Regex on a single field User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->get(); // Fuzzy match on a single field User::viaIndex()->whereFuzzy('last_name', 'phillp')->get(); // Match with operator control User::viaIndex()->whereMatch('bio', 'laravel elasticsearch', 'and')->get(); ``` Also available: `whereTerm()`, `whereExact()`, `wherePrefix()`, `wherePhrase()`, `wherePhrasePrefix()`, `whereScript()` [Full reference: Elasticsearch Queries →](https://elasticsearch.pdphilip.com/eloquent/es-queries/) --- ## Eloquent Queries All the familiar Laravel query methods work exactly as you'd expect. ```php User::viaIndex()->where('state', 'active')->get(); User::viaIndex()->where('orders', '>=', 10)->get(); User::viaIndex()->whereIn('state', ['active', 'pending'])->get(); User::viaIndex()->whereBetween('created_at', [$start, $end])->get(); User::viaIndex()->whereNull('deleted_at')->get(); User::viaIndex()->where('name', 'like', '%max%')->get(); ``` Chain them with search methods for powerful filtered searches: ```php User::viaIndex() ->searchTerm('elasticsearch') ->where('state', 'active') ->whereBetween('created_at', [now()->subYear(), now()]) ->orderByDesc('created_at') ->paginate(10); ``` [Full reference: Eloquent Queries →](https://elasticsearch.pdphilip.com/eloquent/eloquent-queries/) --- ## Geo Queries Filter and sort by geographic distance. ```php // Find users within 5km of a point User::viaIndex() ->filterGeoPoint('home.location', '5km', [40.7128, -74.0060]) ->orderByGeo('home.location', [40.7128, -74.0060]) ->get(); // Bounding box filter User::viaIndex() ->filterGeoBox('home.location', [41.0, -74.5], [40.5, -73.5]) ->get(); ``` [Full reference: Geo Queries →](https://elasticsearch.pdphilip.com/eloquent/es-queries/#geo-queries) --- ## Nested Object Queries Search across [embedded relations](https://elasticlens.pdphilip.com/embedded-relations) as nested objects. ```php // Users with profiles mentioning "elasticsearch" User::viaIndex()->whereNestedObject('profiles', function ($query) { $query->where('profiles.about', 'like', '%elasticsearch%'); })->get(); // Combine nested with other filters User::viaIndex() ->where('state', 'active') ->whereNestedObject('logs', function ($query) { $query->where('logs.action', 'login') ->where('logs.created_at', '>=', now()->subWeek()); }) ->get(); ``` [Full reference: Nested Queries →](https://elasticsearch.pdphilip.com/eloquent/nested-queries/) --- ## Aggregations Run analytics directly on your indexed data. ```php User::viaIndex()->where('state', 'active')->avg('orders'); User::viaIndex()->where('state', 'active')->sum('revenue'); User::viaIndex()->min('created_at'); User::viaIndex()->max('last_login'); // Distinct values User::viaIndex()->distinct('state'); // Group by with aggregations User::viaIndex()->groupBy('state')->get(); ``` Also available: `stats()`, `percentiles()`, `cardinality()`, `extendedStats()`, `matrix()`, `boxplot()`, `medianAbsoluteDeviation()` [Full reference: Aggregations →](https://elasticsearch.pdphilip.com/eloquent/aggregation/) --- ## Highlighting Get search highlights showing exactly where terms matched. Use `getIndex()` to access the highlights on the index model. ```php $results = User::viaIndex() ->searchTerm('espresso') ->withHighlights() ->getIndex(); foreach ($results as $indexedUser) { $indexedUser->getHighlights(); // ['bio' => ['Loves espresso and jazz']] } ``` Custom highlight tags: ```php User::viaIndex() ->searchTerm('espresso') ->withHighlights('', '') ->getIndex(); ``` --- ## Full Reference Every query method available through `viaIndex()` is documented in the Laravel-Elasticsearch package docs: - [**Search Queries**](https://elasticsearch.pdphilip.com/eloquent/search-queries/) - searchTerm, searchPhrase, searchFuzzy, searchPhrasePrefix, searchBoolPrefix, searchQueryString - [**Elasticsearch Queries**](https://elasticsearch.pdphilip.com/eloquent/es-queries/) - whereMatch, whereRegex, whereFuzzy, whereTerm, wherePrefix, wherePhrase, geo queries - [**Eloquent Queries**](https://elasticsearch.pdphilip.com/eloquent/eloquent-queries/) - where, whereIn, whereBetween, whereNull, whereDate, like, orWhere - [**Nested Queries**](https://elasticsearch.pdphilip.com/eloquent/nested-queries/) - whereNestedObject, whereNotNestedObject, filterNested, orderByNested - [**Aggregations**](https://elasticsearch.pdphilip.com/eloquent/aggregation/) - avg, sum, min, max, stats, percentiles, distinct, groupBy, cardinality - [**Ordering & Pagination**](https://elasticsearch.pdphilip.com/eloquent/ordering-and-pagination/) - orderBy, orderByGeo, orderByNested, paginate, skip, take --- ## Base Model Observers By default, the **Base Model** will be observed for changes (saves) and deletions. When the **Base Model** is deleted, the corresponding **Index Model** will also be deleted. > **Note:** > Soft delete behavior is configurable. See [Soft Delete Support](https://elasticlens.pdphilip.com/soft-deletes) for details on controlling whether soft-deleted models keep or lose their index records. ### Handling Embedded Models When you define a `fieldMap()` with embedded fields, the related models are also observed. For example: > A save or delete action on `ProfileStatus` will trigger a chain reaction, fetching the related `Profile` and then `User`, which in turn initiates a rebuild of the index for that user record. > However, to ensure these observers are loaded, you need to reference the User model explicitly: ```php //This alone will not trigger a rebuild $profileStatus->status = 'Unavailable'; $profileStatus->save(); //This will new User::class $profileStatus->status = 'Unavailable'; $profileStatus->save(); ``` ### `HasWatcher` trait If you want ElasticLens to observe an embedded model independently, you can use the `HasWatcher` trait. This allows you to define a watcher for a specific related model which will trigger a rebuild of a specific index model. ### 1. Add the `HasWatcher` Trait to `Embedded Model`: ```php //App\Models\ProfileStatus.php +use PDPhilip\ElasticLens\HasWatcher; class ProfileStatus extends Eloquent { + use HasWatcher; ``` ### 2. Define the Watcher in the `elasticlens.php` Config File: ```php // config/elasticlens.php 'watchers' => [ + \App\Models\ProfileStatus::class => [ + \App\Models\Indexes\IndexedUser::class, + ], +], ``` The watchers definition maps the watched model to trigger which index model to rebuild. ### Disabling Base Model Observation If you want to disable the automatic observation of the **Base Model**, include the following in your **Index Model**: ```php class IndexedUser extends IndexModel { protected $baseModel = User::class; + protected $observeBase = false; ``` --- --- ## Conditional Indexing Control which records get indexed by defining conditions on your **Base Model**. ## The `excludeIndex()` Method By default, every record in your **Base Model** is indexed. To exclude specific records, override the `excludeIndex()` method in your model: ```php class User extends Model { use Indexable; public function excludeIndex(): bool { return $this->status === 'banned'; } } ``` When `excludeIndex()` returns `true`, the record will be skipped during indexing. This applies to both bulk builds (`lens:build`) and automatic syncs via observers. ## How It Works During the build process, ElasticLens calls `excludeIndex()` on each **Base Model** record. If it returns `true`: - The record is skipped and no index entry is created - The build state is marked as **skipped** (not failed) - If the record was previously indexed, the stale index entry will remain until the next full build or manual cleanup ## Cleaning Up Stale Records When you add or change `excludeIndex()` conditions on an existing model, previously indexed records that now meet the exclusion criteria will still have index entries. Run a full rebuild to clean them up: ```bash php artisan lens:build User ``` The build process will skip excluded records and remove any stale index entries for records that no longer qualify. ## Health Check Accounting The `lens:health` command accounts for excluded records. Skipped records are tracked separately from errors, so your health report accurately reflects the state of your index. ```bash php artisan lens:health User ``` --- --- ## Soft Delete Support Configure how ElasticLens handles soft-deleted **Base Models**. ## Default Behavior By default, when a **Base Model** is soft-deleted, its corresponding **Index Model** record is **deleted**. This means soft-deleted records are not searchable. ## Keeping Index Records on Soft Delete If you want soft-deleted records to remain in the index (with their `deleted_at` timestamp synced), enable the `index_soft_deletes` config option: ### Global Configuration ```php // config/elasticlens.php return [ 'index_soft_deletes' => true, // ... ]; ``` When enabled, soft-deleting a model will trigger an index **rebuild** instead of a deletion, syncing the `deleted_at` field to the index. ### Per-Model Override You can override the global setting on individual **Index Models** using the `$indexSoftDeletes` property: ```php class IndexedUser extends IndexModel { protected $baseModel = User::class; // Override global config for this index protected ?bool $indexSoftDeletes = true; } ``` The per-model setting takes priority over the global config: - `$indexSoftDeletes = true` - Always keep index records on soft delete - `$indexSoftDeletes = false` - Always remove index records on soft delete - `$indexSoftDeletes = null` (default) - Fall back to global `index_soft_deletes` config ## Restore Behavior When a soft-deleted model is restored, ElasticLens automatically triggers an index rebuild regardless of the soft delete configuration. This ensures the index is always up to date after a restore. ## Observer Flow The observer handles soft deletes through three events: - **`deleting`** - If soft delete with index retention, skips deletion (lets `deleted` handle it). Otherwise, deletes the index record. - **`deleted`** - If the model is trashed and `shouldIndexSoftDeletes()` is true, triggers a rebuild to sync `deleted_at`. - **`restored`** - Always triggers a rebuild to sync the restored state. --- --- ## Artisan CLI Tools ElasticLens provides a set of Artisan commands to manage and monitor your indexes. ## 1. Status Command Overall Indexes Status ```bash php artisan lens:status ``` Displays the overall status of all your indexes and the ElasticLens configuration. ## 2. Health Command Index Health ```bash php artisan lens:health User ``` Provides a comprehensive state of a specific index, in this case, for the `User` model. ## 3. Migrate Command Migrate and Build/Rebuild an Index ```bash php artisan lens:migrate User ``` Deletes the existing User index, runs the migration, and rebuilds all records. ## 4. Make Command Create a new **Index Model** ```bash php artisan lens:make Profile ``` Generates a new index for the `Profile` model. ## 5. Build Command Bulk (re)build indexes: ```bash php artisan lens:build Profile ``` Rebuilds all the `IndexedProfile` records for the `Profile` model. --- --- ## Build and Migration States ElasticLens provides built-in Elasticsearch models to track the state of your index builds and migrations. The `IndexableBuild` and `IndexableMigrationLog` models are Elasticsearch models that you can access directly using the Laravel-Elasticsearch package. ## Accessing `IndexableBuild` model > ElasticLens includes a built-in `IndexableBuild` model that allows you to monitor and track the state of your index builds. > This model records the status of each index build, providing you with insights into the indexing process.
Fields ```php /** * PDPhilip\ElasticLens\Models\IndexableBuild ******Fields******* * @property string $model // The base model being indexed. * @property string $model_id // The ID of the base model. * @property string $index_model // The corresponding index model. * @property string $last_source // The last source of the build state. * @property IndexableStateType $state // The current state of the index build. * @property array $state_data // Additional data related to the build state. * @property array $logs // Logs of the indexing process. * @property Carbon $created_at // Timestamp of when the build state was created. * @property Carbon $updated_at // Timestamp of the last update to the build state. ******Attributes******* * @property-read string $state_name // The name of the current state. * @property-read string $state_color // The color associated with the current state. **/ ```
Built-in methods include: ```php use PDPhilip\ElasticLens\Models\IndexableBuild; IndexableBuild::returnState($model, $modelId, $indexModel); IndexableBuild::countModelErrors($indexModel); IndexableBuild::countModelRecords($indexModel); ``` > **Note:** > While you can query the `IndexableBuild` model directly, avoid writing or deleting records within it manually, as this can interfere with the health checks and overall integrity of the indexing process. > > >The model should be used for reading purposes only to ensure accurate monitoring and reporting. --- ## Access `IndexableMigrationLog` model > ElasticLens includes a built-in `IndexableMigrationLog` model for monitoring and tracking the state of index migrations. > This model logs each migration related to an `Index Model`.
Fields ```php /** * PDPhilip\ElasticLens\Models\IndexableBuild ******Fields******* * @property string $index_model // The migrated Index Model * @property IndexableMigrationLogState $state // State of the migration * @property array $map // Migration map that was passed to Elasticsearch. * @property int $version_major // Major version of the indexing process. * @property int $version_minor // Minor version of the indexing process. * @property Carbon $created_at // Timestamp of when the migration was created. ******Attributes******* * @property-read string $version // Parsed version ex v2.03 * @property-read string $state_name // Current state name. * @property-read string $state_color // Color representing the current state.. **/ ```
Built-in methods include: ```php use PDPhilip\ElasticLens\Models\IndexableMigrationLog; IndexableMigrationLog::getLatestVersion($indexModel); IndexableMigrationLog::getLatestMigration($indexModel); ``` > **Note:** > While you can query the `IndexableMigrationLog` model directly, avoid writing or deleting records within it manually, as this can interfere with versioning of the migrations. > > >The model should be used for reading purposes only to ensure accurate monitoring and reporting. ---