Skip to content

Embedded Relations

The real power of ElasticLens: embed your relationships into Elasticsearch and search across everything at once.

Consider a typical User model with related data spread across multiple tables:

Diagram

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.


Start with the Index Model and define what gets indexed. We’ll build it up step by step.

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.

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.

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.

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');
});
});

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();

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');
});

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');
});
});
}
}

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.


Now you can search across everything:

// Find users with profiles mentioning "elasticsearch"
User::viaIndex()->whereNestedObject('profiles', function ($query) {
$query->where('profiles.about', 'like', '%elasticsearch%');
})->get();
// 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();
// 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.


When embedded models change, ElasticLens automatically traces back to the base model and rebuilds.

Diagram

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.


If you want to observe a related model independently (outside of the fieldMap chain), use the HasWatcher trait:

App\Models\ProfileStatus.php
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.

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.


Embed TypeMethodObserves by default
Has ManyembedsMany()Yes
Has OneembedsOne()Yes
Belongs ToembedsBelongTo()Yes
Any + skip observer.dontObserve()No
Any + query constraint5th parameter closureYes

For the full API reference, see Field Mapping.