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

Laravel Eloquent Join Laravel Package

fico7489/laravel-eloquent-join

Laravel Eloquent Join adds SQL JOIN support to Eloquent models and relationships, letting you sort, filter, and query joined tables directly while keeping familiar query builder syntax. It simplifies complex relationship queries and helps avoid extra queries.

Deep Wiki
Context7

Getting Started

Install and wire it up

composer require fico7489/laravel-eloquent-join

The package works through a custom Eloquent builder, so the model you start the query from needs the trait:

use Fico7489\Laravel\EloquentJoin\Traits\EloquentJoin; // confirm exact namespace in your pinned version

class User extends Model
{
    use EloquentJoin;
}

The cleanest long-term move is to put the trait on your BaseModel so every query can use it, rather than sprinkling it on individual models.

First query

$users = User::joinRelationship('posts')
    ->select('users.*')          // you almost always want this
    ->where('posts.published', true)
    ->get();

That replaces the hand-written ->join('posts', 'posts.user_id', '=', 'users.id') plus the guesswork about foreign keys, owner keys and table names. The package reads your relationship methods (posts(), comments(), etc.) and derives the join for you.

Where to look first

  1. The README's usage section — it is short and lists the supported relationship types and the alias helpers.
  2. src/Traits/EloquentJoin.php and the builder class it returns — that's where the magic actually lives.
  3. The tests/ directory — the most reliable documentation of which relation types (belongsTo, hasMany, hasManyThrough, belongsToMany, morph variants) are covered and how nested chains behave.
  4. The 2025-05 release / CHANGELOG — pin your Laravel and PHP versions against it before rolling this into a large codebase.

A good first real use case: take a whereHas('posts', fn($q) => $q->where('published', true)) that shows up in a slow listing endpoint and convert it to joinRelationship('posts')->where('posts.published', true). Dump ->toSql() and ->getBindings() before and after to confirm the generated join is what you expect.

Implementation Patterns

Inner vs. left joins for filtering vs. reporting

Use joinRelationship() when you require at least one related row (filtering). Use leftJoinRelationship() when the relation is optional and you don't want to drop parents:

User::leftJoinRelationship('profile')
    ->select('users.*', 'profiles.bio')
    ->whereNull('profiles.id')   // users without a profile
    ->get();

Nested chains

User::joinRelationship('posts.comments')
    ->select('users.*')
    ->where('comments.approved', true)
    ->distinct()
    ->get();

Dot notation keeps the intent readable and saves you three manual joins. Keep the chain short — beyond two or three levels, generated SQL gets hard to reason about and the alias problem (below) gets worse.

Aliases for self-joins and repeated tables

Joining the same table twice (e.g. posts as author's posts and as commenter's posts) needs aliases. The package supports an as form and/or a dedicated alias helper depending on version:

User::joinRelationship('posts as authored_posts')
    ->joinRelationship('comments as recent_comments')
    ->select('users.*')
    ->where('authored_posts.published', true)
    ->get();

Whichever helper your version exposes (joinRelationshipUsingAlias() is the auto-alias variant in recent releases), qualify every column you reference with the alias afterwards.

Constraining the joined table

If your version supports the callback form, put join-time filters next to the join rather than in a where() after it — it reads better and keeps everything about that relation in one place:

User::joinRelationship('posts', function ($join) {
    $join->where('posts.published', true);
})->select('users.*')->get();

Aggregates on the joined table

This is where join-based querying shines compared to withCount() subqueries:

User::joinRelationship('orders')
    ->selectRaw('users.id, users.name, SUM(orders.total) as lifetime_value')
    ->groupBy('users.id', 'users.name')
    ->havingRaw('SUM(orders.total) > ?', [1000])
    ->get();

Note the explicit groupBy of every selected column — required on Postgres, good practice everywhere.

Reusable scopes

Wrap the pattern in a model scope so callers don't re-derive the join:

public function scopeWithPublishedPosts(Builder $q): Builder
{
    return $q->joinRelationship('posts')
        ->select('users.*')
        ->where('posts.published', true)
        ->distinct();
}

Be careful combining scopes that both add select('users.*') and joins — the second one silently wins/duplicates columns.

Mixing with eager loading

Joins and with() are not mutually exclusive. A common hybrid: join to filter and sort, eager load to render.

Post::joinRelationship('author')
    ->select('posts.*')
    ->where('users.active', true)
    ->with('author')
    ->orderBy('users.name')
    ->paginate(25);

Gotchas and Tips

Always select the base table explicitly. A bare joinRelationship(...)->get() returns select * across both tables, and any id/name/created_at collision means the wrong value wins. ->select('users.*') should be muscle memory.

Row multiplication is your problem, not the package's. Joining a hasMany produces one row per child. ->distinct() fixes the display, but it also interacts badly with paginate() — the count query may not respect the distinct and report an inflated total. If you paginate a one-to-many join, verify the count against a manual count(distinct users.id) and fall back to a subquery whereIn or whereHas if the numbers disagree.

Global scopes and SoftDeletes are not applied to the joined model. joinRelationship('posts') builds the SQL from the relationship definition; it does not run Post's global scopes. Soft-deleted children will be included unless you add ->whereNull('posts.deleted_at') yourself. This is the most common source of "the join returns different counts than with()" bugs.

Constraint-bearing relationships need checking. If your relation method is hasMany(Post::class)->where('published', true), confirm whether the package honours that constraint or only the key columns. Test it against a known dataset before assuming.

Ambiguous column errors on ordering. orderBy('name') will fail once two tables share the column. Qualify everything: orderBy('users.name'). Same for where, having, and selectRaw.

Aliasing is mandatory for self-joins. Without it you'll get "table already joined" or silently wrong results. When a relationship is joined twice in one query (including via nested chains that revisit a table), alias the second occurrence.

Polymorphic relations are the weak spot. morphTo/morphMany joins need the *_type discriminator to be correct and can behave differently from the hasMany path. Test morph chains separately and don't assume they're covered just because the trait accepts them.

Debugging workflow. Use ->toSql() and ->getBindings() (or ->dd() / ->dump() on the builder) rather than reading the query after execution. If the SQL looks right but results are wrong, the cause is almost always a scope, a soft delete, or row multiplication — not the join itself. DB::listen() is useful for catching joins added implicitly by scopes.

Extension point. If you hit an unsupported relation type, extend the builder rather than forking the package, or drop back to a manual ->join() for that one query. A hand-written join for a genuinely weird relation is cheaper to maintain than patching the package.

Pin and verify on upgrade. Release cadence is steady (last release May 2025) but the trait/builder API and alias helpers have shifted across major versions. Read the CHANGELOG, and keep a couple of SQL-assertion tests around your trickiest joins so an upgrade that changes generated SQL fails loudly.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
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