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.
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.
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.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.use Tjbp\EloquentJoins\JoinsRelationships;
class User extends Model
{
use JoinsRelationships;
public function posts() { return $this->hasMany(Post::class); }
}
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.
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.
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.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.Post will still match your join. Append ->whereNull('posts.deleted_at') manually — and expect to forget it at least once.toSql() output for each joined scope so a framework bump fails loudly in CI instead of in production.->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.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.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.How can I help you explore Laravel packages today?