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

Eloquent Power Joins Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The README's five numbered sections map 1:1 to the five features you'll actually use: joinRelationship, powerJoinHas/powerJoinWhereHas, orderByPowerJoins*, aliases, and soft-delete/scope handling.
  2. When something looks wrong, go straight to ->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.
  3. The 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.

Implementation Patterns

Pattern: filtering + sorting in one join

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.

Pattern: nested chains with per-level callbacks

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.

Pattern: reusing model scopes as join conditions

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.

Pattern: aliases for self-joins and repeated tables

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.

Pattern: swapping whereHas for join-based existence

User::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.

Pattern: sorting by aggregates

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)')]);

Pattern: polymorphics and soft deletes handled for you

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());

Pattern: global scopes on the joined model

Off by default for performance/predictability. Enable per-join:

UserProfile::joinRelationship('users', fn ($join) => $join->withGlobalScopes());

Integration tips

  • Keep 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.
  • Relationships with conditions bake in. 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.
  • Compose with withCount. Where you need both the aggregate value and the sort, withCount + orderByPowerJoinsCount is often redundant; pick one.
  • Model scopes on the parent are the right encapsulation layer: one scope per business filter, implemented with joinRelationship, keeps controllers free of join syntax.

Gotchas and Tips

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.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core