kirschbaum-development/eloquent-power-joins
Eloquent Power Joins brings Laravel's magic to joins: define joins using your relationship methods, use model scopes in any context, query relationship existence via joins instead of where exists, and sort by related columns or aggregates with less code.
Install and verify
composer require kirschbaum-development/eloquent-power-joins
No service provider registration, no config file, no publishing, no model trait in current releases — the Eloquent builder macros are registered automatically. (If you're following an old blog post that tells you to use PowerJoins on every model, that's from the 2.x era. Version constraints matter: 3.* for Laravel < 10, 2.* for Laravel < 8, current majors for 11/12/13.)
Smoke-test it in Tinker before touching real code:
User::joinRelationship('posts')->toSql();
// select "users".* from "users" inner join "posts" on "posts"."user_id" = "users"."id"
Where to look first
joinRelationship, powerJoinHas/powerJoinWhereHas, orderByPowerJoins*, aliases, and soft-delete/scope handling.->toSql() — this package's whole value proposition is that it generates SQL you could have written by hand, so read the SQL it produced and diff it against what you expected.PowerJoinClause object your callback receives is the real API surface: everything on JoinClause plus as(), withTrashed(), onlyTrashed(), withGlobalScopes(), left(), right().First use case: an admin index page
Take an existing index query that manually joined a table to filter or sort, and swap the hand-written join:
// Before
User::select('users.*')
->join('posts', 'posts.user_id', '=', 'users.id')
->where('posts.published', true)
->groupBy('users.id');
// After
User::joinRelationship('posts', fn ($join) => $join->where('posts.published', true))
->groupBy('users.id');
Same SQL, but the join condition now lives in the Post model's relationship definition instead of being duplicated in every controller. That's the payoff: renames, polymorphic types, composite keys, and soft deletes are handled in one place.
The second use case worth reaching for immediately is replacing a whereHas on a large table with powerJoinHas — see Gotchas before you ship that, because it changes row semantics.
The most common day-to-day query is "show me parents, filtered and sorted by child data." Join once, then reuse the joined table for both:
User::joinRelationship('posts', [
'posts' => fn ($join) => $join->where('posts.published', true),
])
->where('posts.views', '>', 100)
->orderBy('posts.created_at', 'desc')
->groupBy('users.id')
->paginate();
Prefer this over orderByPowerJoins when you're already joining — otherwise you pay for a second join/subquery on the same relationship.
The array syntax keys on relationship segment names, which makes multi-level filters readable:
User::joinRelationship('posts.comments', [
'posts' => fn ($join) => $join->where('posts.published', true),
'comments' => fn ($join) => $join->where('comments.approved', true),
])->get();
For belongs-to-many you get a second level of keys for the pivot table:
User::joinRelationship('groups', [
'groups' => [
'groups' => fn ($join) => $join->where('groups.visibility', 'public'),
'group_members' => fn ($join) => $join->where('group_members.active', true),
],
])->get();
The rule: any relationship that crosses an intermediary table (belongsToMany, hasManyThrough) takes the array-with-table-names form. Get this wrong and you'll see the pivot table joined without your condition, silently.
This is the feature that changes how you organize code. Scopes on the joined model become callable on the join clause:
class Post extends Model
{
public function scopePublished($query)
{
$query->where('posts.published', true);
}
}
User::joinRelationship('posts', fn ($join) => $join->published())->get();
Two constraints worth internalizing up front: don't type-hint $query in the scope signature, and only use conditions joins support (where, whereIn, whereNull — not whereHas, withCount, or anything that spawns a subquery). A safer habit is to write dedicated join-scoped methods on the model rather than reusing scopes that also serve normal Eloquent queries.
Joining categories twice (category and its parent) requires aliases. Use joinRelationshipUsingAlias for the automatic case, and as() inside callbacks when you need control or nesting:
Post::joinRelationshipUsingAlias('category.parent')->get();
Post::joinRelationship('category.parent', [
'category' => fn ($join) => $join->as('category_alias'),
'parent' => fn ($join) => $join->as('category_parent'),
])->get();
Once aliased, all your where/orderBy references must use the alias, not the table name. This is the single most common source of "column not found" errors in this package.
whereHas for join-based existenceUser::powerJoinHas('posts');
User::powerJoinHas('posts.comments');
User::powerJoinHas('posts.comments', '>', 3); // note: operator is 2nd, count 3rd
User::powerJoinDoesntHave('posts');
User::powerJoinWhereHas('posts', fn ($join) => $join->where('posts.published', true));
For multi-table relationships, use the array callback form:
User::powerJoinWhereHas('commentsThroughPosts', [
'comments' => fn ($query) => $query->where('body', 'a'),
])->get();
Workflow tip: keep these behind query scopes (scopePublished, scopeWithActiveComments) so the whereHas → join swap is a one-line change per scope rather than a codebase-wide sed.
User::orderByPowerJoinsCount('posts.id', 'desc')->get();
Post::orderByPowerJoinsAvg('comments.votes', 'desc')->get();
Post::orderByLeftPowerJoinsMax('comments.votes')->get();
Use the Left variants whenever the relationship may be empty. An inner join will drop users with zero posts entirely — the aggregate sort then silently returns fewer rows than the table has. This is the bug that reaches production most often with this package.
orderByPowerJoins also accepts raw expressions for composite sorts:
User::orderByPowerJoins(['profile', DB::raw('concat(city, ", ", state)')]);
morphMany/morphOne joins automatically append the imageable_type constraint. The inverse needs an explicit hint:
Post::joinRelationship('images'); // type filter auto-applied
Image::joinRelationship('imageable', morphable: Post::class); // one type at a time
Soft-deleted models get deleted_at is null appended to the join automatically. Opt out or invert in the callback:
UserProfile::joinRelationship('users', fn ($join) => $join->withTrashed());
UserProfile::joinRelationship('users', fn ($join) => $join->onlyTrashed());
Off by default for performance/predictability. Enable per-join:
UserProfile::joinRelationship('users', fn ($join) => $join->withGlobalScopes());
select() explicit. The package auto-adds parent.* only when you haven't specified columns; being explicit (->select('users.id', 'users.name')) makes behavior obvious and prevents accidental wide selects.hasMany(Post::class)->published() gets applied automatically when you join publishedPosts. That's usually what you want — but it means a join and a whereHas on the same relation can behave differently if the relation definition drifts.withCount. Where you need both the aggregate value and the sort, withCount + orderByPowerJoinsCount is often redundant; pick one.joinRelationship, keeps controllers free of join syntax.Row multiplication is the top hazard. Joining a hasMany returns one row per child. User::joinRelationship('posts')->count() counts posts-in-user-rows, not users. Always distinct() or groupBy('users.id') before paginating, and be aware that distinct() with select users.* on a wide table can be expensive — select fewer columns.
Joins are not where exists. powerJoinHas is faster on many schemas but changes semantics: it can duplicate rows and interacts differently with other joins and with orWhere groupings. Validate any migration from has()/whereHas() by comparing both result sets on production-shaped data before deleting the old code path.
Aggregate sorts get inflated by other joins. If your query already joins another one-to-many table, a COUNT aggregate can be multiplied by the duplicate rows. Isolate the aggregate query, or verify with ->toSql() and EXPLAIN.
Don't type-hint $query in scopes used inside joins. Laravel's normal scope resolution passes an Eloquent\Builder; inside a join it's a JoinClause. A Builder type hint throws. Same trap applies to global scopes with withGlobalScopes() — those must not type-hint Eloquent\Builder in apply().
Column ambiguity after joins. Once two tables are in play, unqualified columns break. Qualify everything in callbacks (posts.published, not published) and in any chained where/orderBy. When aliasing, qualify with the alias.
Auto-select timing. The auto parent.* is applied based on whether columns were set. If you call ->select(...) after joinRelationship, you can end up with behavior that surprises you. Convention: select() first, then the join.
String aliases don't work for nested joins. joinRelationshipUsingAlias('category', 'alias') is fine for a single hop; anything nested requires ->as() in the per-segment callbacks. Also note the alias must be applied on every segment you want aliased — mixing aliased and non-aliased segments in one chain produces SQL that references the wrong table name.
morphTo supports one type at a time. There's no way to join a morphTo across multiple morphable_type values in a single call. If you need "any imageable," you're back to whereHas or a union.
Soft-delete conditions can silently change your counts. A join to a soft-deleting model hides trashed rows; if a powerJoinHas count looks lower than expected, check whether the child model uses SoftDeletes.
Don't expect the relationship to be loaded. joinRelationship returns flat parent models — $user->posts still triggers a query. Joins are for filtering/sorting; use with()/load() for hydration, and be comfortable mixing both on the same query.
Multiple redundant conditions. A relationship definition with ->published() plus a callback where('posts.published', true) produces the condition twice. Harmless in SQL, confusing when reading toSql(). Prefer relying on the relationship definition alone.
Debugging recipe. ->toSql() + ->getBindings(), or ->toRawSql() on Laravel 10+, is usually enough. For "query returns nothing weirdly," check in this order: (1) inner vs left join dropping nullable rows, (2) alias mismatch, (3) a soft delete or polymorphic condition you forgot was automatic, (4) a global scope on the joined model that isn't applied but you assumed was.
Performance checklist before shipping. Index the foreign keys on both sides; prefer powerJoinHas over whereHas only after checking that the dedupe strategy (distinct vs groupBy) doesn't erase the win; avoid stacking three or more aggregate sorts on a paginated query — those are the queries that page-scan and time out.
Extension points. The $join callback receives a PowerJoinClause extending Laravel's JoinClause, so any JoinClause macro you've registered (or the standard on(), whereNull(), whereIn(), orOn()) composes with the package's as(), left(), right(), withTrashed(), onlyTrashed(), withGlobalScopes(). If you find your team repeating the same join-condition block, wrap it in a model scope or a small custom macro — that's the intended extension surface, and it keeps the "hide implementation details" promise the package is built around.
Upgrade hygiene. The changelog is short and dense (2.2.1 fixed nested conditions in relationship definitions; 2.2.2 fixed nested closures with aliases; 1.1.0 added aliases). Since those fixes touch exactly the nested/aliased queries that are hardest to reason about, pin the version and re-run your join-heavy test suite on upgrade rather than trusting semver.
How can I help you explore Laravel packages today?