Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Eager Load Pivot Relations Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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 12
  • v3.x: Laravel 11
  • v2.x: Laravel 10, PHP 8.1+
  • v1.x: Laravel 8/9/10

There is no service provider, config file, migration, or facade. The entire integration is one trait.

Minimum working setup

  1. Add EagerLoadPivotTrait to the model that declares the belongsToMany relation you will eager load. For Plan::with('items.pivot.unit'), that is Plan.
  2. Create a normal Pivot model with the relation methods.
  3. Use ->using(PivotModel::class) on the belongsToMany relation.
  4. Include the pivot foreign key columns in ->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

  • README usage section for the pivot syntax and custom accessor example.
  • Package tests for the most reliable examples: User::with(['cars', 'cars.pivot.color']) and the custom accessor variant Car::with(['users', 'users.car_user.color']).
  • Your own 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.

Implementation Patterns

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.

Gotchas and Tips

  • Put the trait on the parent model, not the pivot model. For 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.
  • The pivot foreign key must be in 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.
  • Custom accessors change the eager-load path. With ->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.
  • Avoid accessor names that collide with real columns or relations. A custom accessor like items or unit will make debugging painful. Prefer the singular pivot model name, e.g. planItem.
  • Nested pivot loading needs traits at each level. 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.
  • Watch for conflicts with custom 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.
  • Use 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.
  • Beware payload size. toArray() will include nested pivot relations. On large collections, use API resources or makeHidden() on pivot relations to keep responses lean.
  • Lazy-loading prevention can surface missed paths. If 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.
  • Pin the package major version to your Laravel version. The package tracks Laravel majors aggressively; v6 requires Laravel 13 and PHP 8.3. Upgrading Laravel without checking the package version is a common source of Composer resolution pain.
  • No config or cache steps. There is nothing to publish. If behavior seems stale after an upgrade, run composer dump-autoload and clear your application caches; there is no package cache.
  • Small package, small surface. With no dependents and a single trait, the integration risk is low, but pin the version and review the trait if you have strict supply-chain requirements. The fallback is straightforward: a custom BelongsToMany subclass or manual pivot-relation loading.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums