audunru/eager-load-pivot-relations
Eager load relations on pivot models in Laravel Eloquent BelongsToMany relationships. Add the EagerLoadPivotTrait to the related model to load nested pivot data such as units or quantities. Supports Laravel 8 and above.
This package is for one specific Eloquent gap: a BelongsToMany pivot model has its own relations, and you want to eager load them with the normal with() syntax. Instead of $item->pivot->unit triggering a query per row, you write Plan::with('items.pivot.unit') and get one extra query per pivot relation level.
Install it with a version that matches your Laravel/PHP stack:
composer require audunru/eager-load-pivot-relations
Version compatibility matters:
v6.x / v5.x: Laravel 13, PHP 8.3+v4.x: Laravel 12v3.x: Laravel 11v2.x: Laravel 10, PHP 8.1+v1.x: Laravel 8/9/10There is no service provider, config file, migration, or facade. The entire integration is one trait.
Minimum working setup
EagerLoadPivotTrait to the model that declares the belongsToMany relation you will eager load. For Plan::with('items.pivot.unit'), that is Plan.Pivot model with the relation methods.->using(PivotModel::class) on the belongsToMany relation.->withPivot().use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use audunru\EagerLoadPivotRelations\EagerLoadPivotTrait;
class Plan extends Model
{
use EagerLoadPivotTrait;
public function items(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'plan_item')
->using(PlanItem::class)
->withPivot('unit_id', 'qty', 'price');
}
}
use Illuminate\Database\Eloquent\Relations\Pivot;
class PlanItem extends Pivot
{
protected $table = 'plan_item';
public function unit()
{
return $this->belongsTo(Unit::class);
}
}
Then the first useful call is:
$plans = Plan::with('items.pivot.unit')->get();
foreach ($plans as $plan) {
foreach ($plan->items as $item) {
echo $item->pivot->unit->name;
}
}
That should produce three queries: plans, items, and pivot units. It will not query units once per item.
Where to look first
pivot syntax and custom accessor example.User::with(['cars', 'cars.pivot.color']) and the custom accessor variant Car::with(['users', 'users.car_user.color']).belongsToMany definitions: confirm ->using() and withPivot() before debugging the package.One documentation quirk: the README’s first example places the trait on Item, while the call is Plan::with('items.pivot.unit'). In practice, put the trait on the model whose relation you are eager loading from — here, Plan. If you also eager load the inverse (Item::with('plans.pivot.unit')), put the trait on Item too.
Basic pivot relation loading
Use the default pivot accessor when the relation does not call ->as():
$plans = Plan::with([
'items.pivot.unit',
'items.pivot.supplier',
])->get();
Access the loaded relation through the pivot object:
$unitName = $plan->items->first()->pivot->unit->name;
The pivot model itself does not need the trait. It stays a standard Pivot subclass.
Custom pivot accessor
If you use ->as('planItem'), eager load with that same accessor name:
public function items(): BelongsToMany
{
return $this->belongsToMany(Item::class, 'plan_item')
->using(PlanItem::class)
->withPivot('unit_id', 'qty', 'price')
->as('planItem');
}
$plans = Plan::with('items.planItem.unit')->get();
foreach ($plans as $plan) {
foreach ($plan->items as $item) {
echo $item->planItem->unit->name;
}
}
Do not mix items.pivot.unit with ->as('planItem'). The accessor name is the path segment.
Nested pivot chains
The package supports nested pivot paths, but each intermediate model that declares the next belongsToMany must also use the trait.
Plan::with([
'items.pivot.unit',
'items.pivot.unit.someBelongsToManyRelation.pivot.anotherRelation',
])->get();
If Unit::someBelongsToManyRelation() is the relation being traversed, Unit needs EagerLoadPivotTrait. The chain is only as complete as the traits on the models declaring each belongsToMany.
Constraining eager loads
You can attach closures to the final nested relation in the usual Laravel way:
Plan::with([
'items.pivot.unit' => fn ($query) => $query->where('active', true),
])->get();
For pivot-level filtering, prefer wherePivot() on the relation definition or constrain the parent relation. Do not assume whereHas('items.pivot.unit') works; pivot-relation paths are an eager-load feature, not a general relationship-query feature.
API resources and serialization
Loaded pivot relations appear under the pivot accessor in toArray() / JSON output. That is convenient for APIs, but can bloat payloads. In resources, select explicitly:
return [
'id' => $this->id,
'name' => $this->name,
'items' => $this->items->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'unit' => $item->pivot->unit?->only('id', 'name'),
'qty' => $item->pivot->qty,
]),
];
For custom accessors, use $item->planItem instead of $item->pivot.
Lazy eager loading after retrieval
load() and loadMissing() work the same way, provided the trait is on the model that declares the relation:
$plan = Plan::findOrFail($id);
$plan->load('items.pivot.unit');
This is useful in controllers when the base query is built elsewhere, but keep automatic eager loading paths in mind if you use Model::preventLazyLoading().
Query-count verification
Treat the package as a performance tool, so verify it. A simple test-time check:
DB::enableQueryLog();
$plans = Plan::with('items.pivot.unit')->get();
$this->assertCount(3, DB::getQueryLog());
For a pivot relation with a nested relation, expect one extra query per relation level, not per row.
When not to use it
If the pivot table only carries scalar columns such as qty and price, you do not need this package. Use withPivot() and read $item->pivot->qty. Add this package when the pivot is a real domain object with its own belongsTo / hasMany relations and you are seeing N+1 queries in loops or API serialization.
Plan::with('items.pivot.unit'), use the trait on Plan. The pivot model should remain a plain Pivot subclass. If you also eager load the inverse relation, add the trait to that side too.->using() is mandatory. Without ->using(PlanItem::class), Eloquent uses the default pivot class and your pivot relation methods do not exist.withPivot(). If plan_item.unit_id is not listed, the pivot model will not have the key needed for $this->belongsTo(Unit::class), and the relation will resolve to null or query incorrectly. Include every pivot column used by pivot relations.->as('planItem'), use items.planItem.unit, not items.pivot.unit. Also ensure the trait is on the model declaring the relation so the package can inspect the relation and detect the accessor.items or unit will make debugging painful. Prefer the singular pivot model name, e.g. planItem.items.pivot.unit.someRelation.pivot.other only works if the model declaring someRelation uses the trait. Missing one trait usually shows up as an N+1 or an unresolved nested path.whereHas('items.pivot.unit') is not the intended API. This package handles eager loading of pivot relations. For filtering, use wherePivot() on the relation, constrain the parent relation, or query the pivot table directly.withCount('items.pivot.unit') is not a supported pattern. Do not expect aggregate helpers to understand pivot-relation paths.morphToMany is not covered by the same trait path. The trait overrides newBelongsToMany(). If you rely on polymorphic many-to-many pivot relations, verify behavior or avoid the package for that relation.BelongsToMany subclasses. If your app or another package already overrides newBelongsToMany(), the trait method may clash or be overridden. Resolve trait conflicts explicitly with insteadof / as, and test the relation.DB::listen or the query log when debugging. toSql() only shows the main query. Eager-load queries are separate. Also inspect $item->pivot->relationLoaded('unit') to confirm the relation was hydrated.toArray() will include nested pivot relations. On large collections, use API resources or makeHidden() on pivot relations to keep responses lean.Model::preventLazyLoading() is enabled outside production, accessing $item->pivot->unit without with('items.pivot.unit') will throw. That is useful in tests; add the path to the query or to $with deliberately.v6 requires Laravel 13 and PHP 8.3. Upgrading Laravel without checking the package version is a common source of Composer resolution pain.composer dump-autoload and clear your application caches; there is no package cache.BelongsToMany subclass or manual pivot-relation loading.How can I help you explore Laravel packages today?