staudenmeir/eloquent-eager-limit-x-laravel-adjacency-list
Compatibility package combining staudenmeir/laravel-adjacency-list with eloquent-eager-limit, letting Laravel apps cap eager-loaded relations on recursive adjacency list queries. Adds the missing trait support so hierarchical models can limit eager loads per parent without N+1 queries.
Confirm the three-way compatibility before writing a line of code. This is a composition package: the bridge, staudenmeir/eloquent-eager-limit, and staudenmeir/laravel-adjacency-list all have to line up with your Laravel and PHP versions. The last bridge release was May 2023, so read composer.json in all three vendor directories rather than trusting the README. Then confirm your database supports window functions — MySQL 8+, Postgres, SQLite 3.25+, SQL Server. MariaDB and MySQL 5.7 are out, full stop.
composer require staudenmeir/eloquent-eager-limit-x-laravel-adjacency-list:^1.2
php artisan tinker
Where to look first. The bridge's own README is thin — it is a glue package, and its changelog entries are inherited from upstream (v1.2 "Add graph relationships" is really an adjacency-list feature). Read in this order: the bridge README (for the exact trait name it wants you to use), then staudenmeir/laravel-adjacency-list's README (that is where 90% of the actual API documentation lives — descendants, ancestors, depth, getLocalKeyName), then eloquent-eager-limit's README for the window-function mechanics.
Wire up one model. The bridge ships the trait variant that lets the eager-limit builder and the recursive builder coexist; using the upstream HasRecursiveRelationships on its own gets you silent limit() dropping.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships; // swap for the bridge trait
class Comment extends Model
{
use HasRecursiveRelationships;
public function getParentKeyName(): string { return 'parent_id'; }
public function getLocalKeyName(): string { return 'id'; }
}
First use case: "three most recent replies" on a thread index. This is the smallest thing that proves the package works and the largest thing teams usually build by loading the whole subtree.
$threads = Comment::whereNull('parent_id')
->with(['children' => fn ($q) => $q->latest('id')->limit(3)])
->get();
DB::listen(fn ($q) => dump($q->sql)); // expect: row_number() over (partition by "parent_id" order by "id" desc)
If you don't see row_number() over (partition by ...) in the SQL, the trait combination isn't active and you are about to ship an N+1. Before going further, seed a tree with one deliberately high-fan-out hub node and assert per-parent counts, not total counts.
Per-parent preview on direct children. The bread-and-butter case. Always pair limit() with a deterministic orderBy inside the closure — without it you get an arbitrary N rows that change between requests.
Post::with([
'comments' => fn ($q) => $q->orderByDesc('created_at')->orderByDesc('id')->limit(3),
])->get();
Recursive descendants with a cap. This is the reason the package exists; limit() on a plain descendants eager load gives you N rows across the whole result set, not N per node.
Category::with([
'descendants' => fn ($q) => $q->orderBy('depth')->orderBy('id')->limit(10),
])->get();
The adjacency-list recursive CTE exposes depth, which is the natural sort key when you want "nearest descendants first" rather than "newest first."
Nested eager loads for a two-level preview. Composition works through the dot notation, and each level gets its own budget:
Comment::with([
'children' => fn ($q) => $q->latest('id')->limit(3),
'children.children' => fn ($q) => $q->latest('id')->limit(2),
])->get();
Do not assume this composes past depth 2 without testing — treat the depth ceiling as something you establish and document, not something you infer.
Offset for "skip the first N." Same shape, just chained; meaningless without an order.
'children' => fn ($q) => $q->orderBy('id')->offset(3)->limit(3),
Wrap it, don't scatter it. Put the query shape behind a scope or repository method so adopting the package is one file, not a call-site sweep:
// app/Models/Concerns/HasPreviewChildren.php
public function scopeWithPreviewChildren($query, int $limit = 3)
{
return $query->with(['children' => fn ($q) => $q->latest('id')->limit($limit)]);
}
This is also your exit ramp: if the dependency goes stale, replacing the closure body with a hand-rolled ROW_NUMBER() OVER (PARTITION BY parent_id) scope is a single-file change.
Combine with aggregates and filters carefully. withCount(['children']) gives you the true total while the preview gives you the slice — that's the "showing 3 of 47" UI. But withCount runs its own query; keep it off the same closure.
Test the shape, not the count. assertDatabaseCount tells you nothing here. Assert per parent:
$threads->each(fn ($t) => $this->assertLessThanOrEqual(3, $t->children->count()));
limit() outside the closure is a different query. ->with('children')->limit(3) caps parents, not children. The whole point is that the limit lives inside the eager-load closure; misplaced parentheses fail quietly.
Silent trait conflict is the #1 failure mode. If limit() inside a descendants closure appears to work but returns the wrong number of rows, you are probably running the upstream adjacency-list trait instead of the bridge trait, and the window-function builder never got installed. Dump the SQL and grep for row_number.
Soft deletes and global scopes are the most likely silent defect. The recursive CTE plus the partition window is a lot of surface for a scope to fall off. Write an explicit test with a trashed node in the middle of a tree and a tenancy/global scope applied, and confirm neither leaks into the preview.
Index the parent column, and index it with the sort column. EXPLAIN the actual generated query. A bare parent_id index helps; a composite (parent_id, created_at) or (parent_id, id) matching your orderBy helps a lot more on high-fan-out nodes. Uniform test data flatters this package badly — benchmark against a hub node with hundreds of children before committing.
Total row count still scales with parent count. Per-parent LIMIT means 50 parents × 3 children = 150 rows, which is fine; 5,000 parents is not. This package solves the fan-out problem, not the pagination problem. If you need "1–10 of 4,382 descendants," you need a different data model.
Cycles are your problem. Nothing in an adjacency list stops A → B → A. Enforce acyclicity at write time (or a depth ceiling via whereDepth) or a recursive CTE will loop until it errors.
ancestors with a limit reads oddly. Limiting an ancestor set caps how far up the chain you hydrate, per node. It's occasionally what you want for breadcrumb truncation; it's more often a mistake.
Pin the exact four-tuple in the PR description — bridge version, eager-limit version, adjacency-list version, Laravel version — and note it in the upgrade checklist. Bridge packages must be re-released per Laravel major; a 2023 release date means the next framework upgrade forces a decision (fork, hand-roll, or wait) rather than a composer update.
v1.2's graph relationships are a PoC budget, not a roadmap commitment. They ride the same recursive-CTE foundation and inherit the same ceilings. Time-box an experiment; don't let it into a customer-facing feature without its own evaluation.
Debugging recipe that actually works: DB::listen filtered to queries containing with recursive, run one request, and read the SQL. It tells you immediately whether the window function fired, whether scopes applied, and whether you're issuing one query or fifty.
How can I help you explore Laravel packages today?