Embedded Relations
The real power of ElasticLens: embed your relationships into Elasticsearch and search across everything at once.
The Scenario
Section titled “The Scenario”Consider a typical User model with related data spread across multiple tables:
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
Section titled “Building the fieldMap”Start with the Index Model and define what gets indexed. We’ll build it up step by step.
Base fields
Section titled “Base fields”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
Section titled “embedsMany - User has many Profiles”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
Section titled “embedsOne - nested within an embed”Profile has one ProfileStatus. You can nest embeds inside embeds:
$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
Section titled “embedsBelongTo - User belongs to Account”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
Section titled “dontObserve - static reference data”For data that rarely changes (like countries), skip the observer to avoid unnecessary rebuilds:
$field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { $field->text('name'); $field->text('currency');})->dontObserve();Query-limited embeds
Section titled “Query-limited embeds”Embed only the 10 most recent logs instead of all of them:
$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
Section titled “The Complete fieldMap”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
Section titled “What Gets Stored”When User #1 is indexed, ElasticLens builds a single Elasticsearch document:
{ "id": 1, "first_name": "David", "last_name": "Philip", "email": "david@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
Section titled “Searching Embedded Fields”Now you can search across everything:
Nested object queries
Section titled “Nested object queries”// Find users with profiles mentioning "elasticsearch"User::viaIndex()->whereNestedObject('profiles', function ($query) { $query->where('profiles.about', 'like', '%elasticsearch%');})->get();Combine with other queries
Section titled “Combine with other queries”// Active users at tech companies with recent login activity from NorwayUser::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
Section titled “Full-text search across embedded data”// Search term across ALL fields - base and embeddedUser::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
Section titled “The Observer Chain”When embedded models change, ElasticLens automatically traces back to the base model and rebuilds.
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
Section titled “Advanced Patterns”Independent watchers with HasWatcher
Section titled “Independent watchers with HasWatcher”If you want to observe a related model independently (outside of the fieldMap chain), use the HasWatcher trait:
use PDPhilip\ElasticLens\HasWatcher;
class ProfileStatus extends Eloquent{ use HasWatcher;}Then register it in config/elasticlens.php:
'watchers' => [ \App\Models\ProfileStatus::class => [ \App\Models\Indexes\IndexedUser::class, ],],See Model Observers for the full configuration reference.
Index migrations for embedded fields
Section titled “Index migrations for embedded fields”When embedding relationships, define nested mappings in your migrationMap():
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 for the full migration reference.
Summary
Section titled “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.