Embedded Relations
The main event. Embed your relationships into Elasticsearch and search across everything at once.
The Problem
Section titled “The Problem”You’ve got a User model with data spread across six tables. You want to search “find all active users who work in tech companies and have a profile mentioning elasticsearch.” In SQL, that’s JOINs across three tables, WHERE clauses stacked up, and still no full-text search.
ElasticLens flattens everything into a single searchable document at index time. Six tables become one query.
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 Elasticsearch. 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. Embeds nest 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 the chain: 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. No point rebuilding indexes every time someone corrects a currency symbol:
$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": "Max", "last_name": "Philip", "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 search across everything. Embedded fields use dot notation, just like you’d expect:
Dot-notation queries
Section titled “Dot-notation queries”// Users with profiles mentioning "elasticsearch"User::viaIndex()->where('profiles.about', 'like', '%elasticsearch%')->get();Combine with other queries
Section titled “Combine with other queries”// Active users at tech companies with recent login activityUser::viaIndex() ->where('state', 'active') ->where('account.industry', 'Technology') ->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”// Searches ALL fields, base and embeddedUser::viaIndex()->searchTerm('elasticsearch')->get();searchTerm searches across every indexed field, including embedded ones. A match in profiles.about surfaces the parent User.
The Observer Chain
Section titled “The Observer Chain”When embedded models change, ElasticLens traces back to the base model and rebuilds automatically.
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 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 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 |
Full API reference: Field Mapping.