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 Joins Laravel Package

tjbp/eloquent-joins

Eloquent Joins adds relationship-aware join helpers to Laravel's query builder, letting you join tables through model relations and reuse constraints without hand-writing ON clauses. A small package that keeps joined queries readable and DRY.

View on GitHub
Deep Wiki
Context7

Getting Started

This package adds a trait that lets you join a relationship's table without hand-writing foreign key conditions. It is archived (last maintained years ago, 17 stars, GPL-3.0), so treat it as a legacy integration rather than a greenfield choice — but it still installs and works on older Laravel versions where you'd otherwise be repeating ->join('posts', 'posts.user_id', '=', 'users.id') in every scope.

  1. Install and pin. composer require tjbp/eloquent-joins — then pin the exact version in composer.json and commit the lockfile. Because the repo is archived, you will not get upstream fixes for new Laravel query-builder internals, so a fixed version is your only safety net.
  2. Read the trait source before the README. Open src/ in vendor/tjbp/eloquent-joins first. The public surface is small (a JoinsRelationships-style trait exposing joinRelation / leftJoinRelation and friends), and the method signatures tell you more than the README about how relation types map to join types and how table aliases are handled.
  3. Wire up one model. Add the trait to the model that owns the relationship:
use Tjbp\EloquentJoins\JoinsRelationships;

class User extends Model
{
    use JoinsRelationships;

    public function posts() { return $this->hasMany(Post::class); }
}
  1. First use case — replace a whereHas with a join. The canonical day-one win is filtering or sorting a list by a related column where the correlated subquery is the bottleneck:
User::joinRelation('posts')
    ->where('posts.published', true)
    ->select('users.*')
    ->groupBy('users.id')
    ->paginate(25);

If the resulting SQL looks right (toSql() is your friend here), you're done with setup.

Implementation Patterns

Filter-and-sort on belongsTo/hasOne. This is the safest use of the package: one-to-one joins never multiply rows, so you can join, select related columns, and sort without any deduplication:

Order::joinRelation('customer')
    ->orderBy('customers.last_name')
    ->select('orders.*', 'customers.last_name')
    ->get();

hasMany joins as a filter only. With a to-many relation the join fans out rows. Use it to filter (->distinct() + groupBy on the parent key) or to aggregate — not to hydrate a list of parents with all their children's columns:

// count of published posts per user, one row per user
User::joinRelation('posts')
    ->where('posts.published', true)
    ->select('users.*', DB::raw('count(posts.id) as published_count'))
    ->groupBy('users.id')
    ->get();

Left joins for optional relations. Whenever the relation may be absent (nullable foreign key, soft-deleted parent), start from the left-join variant so parents without a match survive. Pair it with whereNull('related.id') for the classic anti-join / "orphans" report.

Wrap it in a query scope. The package's value multiplies when the join is reusable. Put the join, the qualified select, and the groupBy in one scope so callers can't reintroduce the duplicate-row bug:

public function scopeWithPostCount(Builder $q): Builder
{
    return $q->leftJoinRelation('posts')
             ->select('users.*', DB::raw('count(posts.id) as posts_count'))
             ->groupBy('users.id');
}

Compose with eager loading. A join is a query concern; with() is a hydration concern. Join to filter and sort, then ->with('posts') only if you actually need the models — and accept the second query. Don't try to reconstruct relationships from joined rows.

Nest joins for multi-hop filters. Chain joinRelation('posts') then joinRelation('posts.comments') (if the trait supports dotted paths — verify in source; otherwise call it twice on the builder with the intermediate table already joined). Check the generated SQL carefully for duplicate alias collisions.

Integration with pagination and counts. paginate() and count() run their own queries; a groupBy in the join scope will make count() return grouped rows rather than a scalar. Use ->distinct('users.id') or override the count query when the join is present.

Gotchas and Tips

  • Column collisions are the #1 bug. id, created_at, updated_at, deleted_at exist in almost every joined table. Always pass qualified select() lists; never select('*'). When you forget, Eloquent silently overwrites the parent's id with the child's and you get bizarre route-model-binding failures.
  • Row multiplication. Any hasMany/belongsToMany join changes your result cardinality. If a page suddenly shows the same user five times, you joined a to-many relation without distinct or groupBy. Add it before you add anything else.
  • Soft deletes and global scopes are not applied to joined tables. The trait builds the join condition; it does not run the related model's global scope. A soft-deleted Post will still match your join. Append ->whereNull('posts.deleted_at') manually — and expect to forget it at least once.
  • GPL-3.0 is not a formality. If you ship this as part of a distributed product, GPL obligations apply to the combined work. For internal/SaaS use it's generally fine; for a shipped binary or a package you redistribute, get a license review or vendor-free reimplement the four lines of join logic yourself.
  • Archived means "frozen in time." The package hooks into the Eloquent builder; a Laravel minor upgrade that changes builder internals can break it with no upstream patch. Add a test that asserts toSql() output for each joined scope so a framework bump fails loudly in CI instead of in production.
  • Debugging recipe. ->toSql() and ->getBindings() on the final builder, not midway — scopes applied later can add group by and change the shape entirely. DB::listen() around the call is the fastest way to see what paginate() actually executes.
  • Extension points. The trait is small and mostly composition; subclass or copy it into app/Support/ and adapt it rather than fighting it. A query-builder macro (Builder::macro('joinRelation', ...)) is a cleaner home for the logic if you want it available everywhere without touching every model.
  • When to skip it. For anything new, prefer native whereHas/withWhereHas plus withCount/withSum for aggregates, and a hand-written ->join() in a dedicated repository or scope for the genuinely hot queries. The package saves keystrokes, not complexity — the foreign key is one line of code. Maintained alternatives such as reedware/laravel-relation-joins cover the same niche with current Laravel support if you need the convenience at scale.
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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings