staudenmeir/eloquent-has-many-deep
Extended HasManyThrough for Laravel 5.5+ lets you define relationships with unlimited intermediate models, supporting many-to-many, polymorphic, and combined relationship types. Define deep relationships by concatenating existing ones or specifying keys manually; includes third-party package support...
Install and pin to your Laravel version. The package tracks Laravel majors, so don't just take latest:
composer require staudenmeir/eloquent-has-many-deep:"^1.20" # Laravel 11.x
(The README has a version table — Laravel 12 → 1.21, Laravel 13 → 1.22, etc. On Windows PowerShell you need "^^^^1.20". Pin it in composer.json like any other first-party dependency.)
Add the trait to the far parent — the model you start from, not the intermediate ones:
class Country extends Model
{
use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
}
Where to look first: the README's Concatenating Existing Relationships section. It is the safest and most maintainable entry point, because it reuses relationships you already trust. Only drop into Defining Relationships Manually when the intermediate path doesn't exist yet or the keys are non-standard.
First use case — kill an N+1. Before:
// N+1: one query per country, then one per post
foreach (Country::all() as $country) {
foreach ($country->posts as $post) { ... $post->comments ... }
}
After, as an eager-loadable relationship:
class Country extends Model
{
use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
public function comments(): \Staudenmeir\EloquentHasManyDeep\HasManyDeep
{
return $this->hasManyDeepFromRelations($this->posts(), (new Post())->comments());
}
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
}
Country::with('comments')->get(); // 2 queries total
Sanity-check the SQL immediately. In tinker: Country::with('comments')->toSql(), or DB::listen(fn ($q) => dump($q->sql, $q->bindings)). Every deep relationship compiles to a single join-based subquery — if the joins or the where bindings look wrong, stop and fix the definition before building anything on top of it.
Prefer concatenation over manual keys. hasManyDeepFromRelations() reads as a sentence, reuses existing scopes, and survives schema changes to the individual relationships. Reserve hasManyDeep(Comment::class, [User::class, Post::class]) for paths that genuinely have no relationship methods, and always pass explicit keys there — a silent convention mismatch is the classic bug.
Constraints are opt-in — and must be qualified. Plain hasManyDeepFromRelations() drops where() clauses from the constituent relationships. Use the callable-array variant and qualify columns that could collide:
return $this->hasManyDeepFromRelationsWithConstraints(
[$this, 'posts'],
[new Post(), 'comments']
);
// where posts() is ->hasManyThrough(...)->where('posts.published', true)
Model the whole graph, including pivots and morphs. Once you accept table names as intermediate entries, many-to-many and polymorphic paths become routine:
// ManyToMany → HasMany
$this->hasManyDeep(Permission::class, ['role_user', Role::class]);
// MorphMany in the path
$this->hasManyDeep(Comment::class, [Post::class], [null, ['commentable_type', 'commentable_id']]);
// BelongsTo in the path — swap foreign/local keys
$this->hasManyDeep(User::class, ['taggables', Post::class], [null, 'id', 'id'], [null, ['taggable_type', 'taggable_id'], 'user_id']);
Lean on the full Eloquent surface. Because HasManyDeep is a real relationship type, everything downstream just works: with(), load(), loadMissing(), whereHas(), has(), withCount(), withSum(), constrained eager loading (with(['comments' => fn ($q) => $q->latest()])), and per-parent scoping. That's where the day-to-day leverage lives — you get deep traversal and query-builder composability.
Use HasOneDeep for "the latest X" questions rather than sorting a collection client-side:
return $this->hasOneDeep(Comment::class, [User::class, Post::class])
->latest('comments.created_at');
Carry intermediate/pivot data when you need it via the package's withIntermediate() / withPivot() so you can attribute a deep result to the row that produced it, without an extra query.
Name relationships after the domain, not the path. Country::comments() reads better than countryUserPostComments(). Compose smaller relationships into one deep one and keep the plumbing in a concern/trait so controllers and resources stay declarative.
Handle self-referencing hierarchies with table aliases (the README's Table Aliases section) — e.g. category trees where the same table appears twice in the path. Use CompositeKey when two tables must match on multiple columns.
Check reversals before hand-rolling them. The README documents Reversing Relationships plus intermediate/pivot constraints and "unique results" helpers; also check compatibility with the sibling packages (eloquent-json-relations, laravel-adjacency-list Tree/Graph, laravel-has-many-merged, compoships) — concatenation across them is the intended extension point.
Deep relationships are for reading, not writing. create()/save() on a HasManyDeep will only touch the final foreign key and will not create the intermediate rows. Treat them as read-only; do writes through the individual relationships or a transactional service method.
Fan-out produces duplicates. Any many-to-many segment in the path multiplies rows. Use ->distinct() (README: Getting Unique Results) or ->unique() in PHP, and be suspicious of withCount() — a count across a pivot fan-out can over-report versus count(distinct ...) on a hand-written query.
Qualify everything. With five joined tables, id, created_at, and status are ambiguous. Qualify in where(), orderBy(), and latest(). This bites hardest inside hasManyDeepFromRelationsWithConstraints().
Pivot key swapping is the #1 source of silent wrong results. On the "right" side of a pivot table, the foreign and local keys swap roles. Build a three-row fixture, assert exact IDs, and only then move on.
Soft deletes on intermediate models change result sets. Read the Soft Deleting section before assuming withTrashed() on the final model propagates — configure it explicitly, otherwise deactivated intermediate rows silently vanish from the path.
chunk() / chunkById() plus joins is fragile. Ambiguous id columns break ordering; always orderBy a qualified column and test pagination at the boundary.
Watch the depth/performance curve. Depth ≤ 3 is comfortable. At depth 4+, every relationship is N joins: index every foreign key on the path, EXPLAIN the eager-load query, and consider a denormalized/closure-table projection if a deep relation shows up on a hot endpoint.
Eager-loading constraints apply to the join, not post-filtering. A where inside with(['comments' => ...]) is fine, but orWhere without a closure grouping will leak and match everything — wrap it.
Type-hint the return types (HasManyDeep, HasOneDeep) and generate IDE helper docblocks; it makes $country->comments discoverable and keeps static analysis (the package itself ships PHPStan level 10) meaningful in your app.
Test the relationship, not just the endpoint. One test asserting Country::find(1)->comments->pluck('id') against a hand-built fixture catches almost every key/alias/pivot mistake, and it's cheap to keep green.
How can I help you explore Laravel packages today?