Skip to content

Embedded Relations

The main event. Embed your relationships into Elasticsearch and search across everything at once.

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.

Diagram

ElasticLens flattens everything into a single searchable document at index time. Six tables become one query.


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 Elasticsearch. The Profile model is automatically observed: update a profile and the parent IndexedUser rebuilds.

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.

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

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": "Max",
"last_name": "Philip",
"email": "[email protected]",
"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 search across everything. Embedded fields use dot notation, just like you’d expect:

// Users with profiles mentioning "elasticsearch"
User::viaIndex()->where('profiles.about', 'like', '%elasticsearch%')->get();
// Active users at tech companies with recent login activity
User::viaIndex()
->where('state', 'active')
->where('account.industry', 'Technology')
->where('logs.action', 'login')
->where('logs.ip', '!=', '127.0.0.1')
->get();
// Searches ALL fields, base and embedded
User::viaIndex()->searchTerm('elasticsearch')->get();

searchTerm searches across every indexed field, including embedded ones. A match in profiles.about surfaces the parent User.


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

Diagram

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.


If you want to observe a related model independently (outside 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

Full API reference: Field Mapping.