atldays/laravel-eloquent-join-relation
Add SQL joins to Laravel Eloquent relationships without dropping to the query builder. atldays/laravel-eloquent-join-relation lets you join related tables through existing relationship definitions, filter and sort by joined columns, and keep clean, readable model code.
This package lets you keep using join-based queries for filtering, sorting, and validation while still ending up with real Eloquent relation objects — no follow-up with() query, no N+1.
Requirements: PHP 8.2+, Laravel 11.x / 12.x / 13.x.
Install and first use case. The canonical starting point is a list query that already joins a parent table for filtering or sorting, and where the view also needs the related model:
// Before: join for the filter, then a second query (or N+1) for the relation
$posts = Post::query()
->join('users', 'users.id', '=', 'posts.user_id')
->where('users.active', true)
->orderBy('users.name')
->select('posts.*')
->get();
// After: the same join now also produces $post->author
$posts = Post::query()
->join('users', 'users.id', '=', 'posts.user_id')
->where('users.active', true)
->orderBy('users.name')
->select('posts.*')
->hydrate('author') // relation name, not table name
->get();
$posts->first()->author->name; // hydrated model, no extra query
Where to look first:
select to that shape before debugging anything else.author), and the hydrator resolves the foreign key and model class from the relation definition. If your relation method is author() but you pass 'users', you'll get an ordered-path or missing-relation exception.First meaningful win: replace a ->with('author') on a query that also needs whereHas-style joining. You get one query instead of two, and the relation is a genuine User model with attributes, casts, and accessors intact.
One relation, joined for filtering. Select the parent columns explicitly (posts.*) plus the related columns, then hydrate. If you omit posts.*, joined columns with the same name (id, created_at) overwrite the parent's attributes.
Nested paths. Nested paths are supported as dotted strings and are hydrated outer-to-inner:
Post::query()
->join('users', 'users.id', '=', 'posts.user_id')
->join('teams', 'teams.id', '=', 'users.team_id')
->join('organizations', 'organizations.id', '=', 'teams.organization_id')
->select('posts.*', /* user, team, org columns */)
->hydrate('author.team.organization')
->get();
$post->author->team->organization->name;
The path order in the string must match the join order, outermost first. This is enforced with an explicit exception — it is not a silent no-op, which makes misordering cheap to fix.
Manual hydration (related + join + hydrate). When you already have a builder or a raw row set — for example a repository method that returns joined rows, or a query built by another service — you can hydrate a single related model manually rather than going through the root model's query. This is the escape hatch for report queries, exports, and read models that don't map cleanly onto a fluent chain. Use it when the query shape is dynamic; keep the fluent form for straightforward controller queries.
Optional relations with left join. left join is handled properly: missing related records hydrate as null, not as a partially-filled model. Write $post->author?->name and be consistent about it — the null is real and is not an "empty model with null attributes".
Combine with pagination and sorting. The main day-to-day pattern is a joined filter plus a joined sort plus a hydrated relation, paginated:
$posts = Post::query()
->join('users', 'users.id', '=', 'posts.user_id')
->where('users.active', true)
->orderBy('users.name')
->select('posts.*', 'users.name as author_name', 'users.id as author_id')
->hydrate('author')
->paginate(25);
Sort on the joined table, but keep the relation's primary key in the select so the hydrator can key the model.
Integration tip — resources and serialization. Hydrated relations behave like normal loaded relations, so PostResource can include $this->whenLoaded('author') / new UserResource($this->author) without special casing. That keeps API layers unchanged when you migrate a query from with() to joined hydration.
Integration tip — query objects. Put the join + select + hydrate triple inside a dedicated query class or scope (scopeForListing) rather than repeating it. The three parts are coupled: a change to the join list usually requires a change to the selected columns and the hydration path. Keeping them adjacent is the single biggest maintainability factor here.
Migration pattern. Introduce it per-query, not globally. Queries that only need the relation should keep using with(). Reach for joined hydration on queries that already join, where the relation is free.
Scope is deliberately narrow. Only BelongsTo and HasOne are supported. HasMany, BelongsToMany, and polymorphic relations are out of scope for v1 — don't try to hydrate them, and don't expect author.posts in a nested path to work. Use with() for those.
Nested path ordering is enforced. Out-of-order paths throw. If you reorder joins for performance, update the hydration string in the same commit. The exception message names the offending segment — start there.
Lazy-loading protection is a feature, not a bug. The package prevents falling back to lazy loading for relations it was asked to hydrate. If a relation wasn't hydrated, accessing it fails fast instead of quietly firing a query. The practical consequence: if you hydrate conditionally, guard the access. And note the interaction with Model::preventLazyLoading() and withoutLazyLoading() in tests — the failure modes look similar, so read the exception class before assuming it's your global setting.
Column collisions silently win. A join brings all columns into the root model's attribute array unless you constrain the select. Always select('posts.*', ...) on the root and alias anything ambiguous (users.id as author_id). Symptoms of getting this wrong: parent id becomes the user's id, timestamps look wrong, or $post->exists behaves oddly.
Row multiplication. Because only singular relations are in scope, one joined row maps to one root model — that's safe. But if you join a one-to-many table for filtering and hydrate a BelongsTo in the same query, you'll get duplicate root models. distinct()/groupBy fixes the result set but complicates the count query behind paginate(). Consider a subquery whereExists for the many-side filter instead.
left join nulls propagate through nested paths. With author.team.organization and a missing team, $post->author->team is null and the chain stops there. Don't assume the inner segments are non-null just because the outermost one resolved.
Hydration bypasses the related model's query builder. Global scopes on the related model (SoftDeletes, tenant scoping, published filters) were not applied when the row was joined. You can hydrate a soft-deleted or out-of-tenant author if your join didn't exclude it. Add the constraint to the join explicitly — this is the most likely source of subtle data-leak bugs in this package.
Hydrated models are real models. They carry exists = true, original attributes, and dirty-state tracking. Calling save() on a hydrated relation performs an update. Treat them as read-mostly; for writes, reload through the normal relation.
Debugging order of operations. When something hydrates as null unexpectedly, check in this order: (1) did the join actually match a row; (2) is the relation's foreign key column present in the select; (3) does the hydration string match the relation method name; (4) is the path order correct. Almost every report resolves at step 2.
Extension points. For custom relation behaviour, define a normal Eloquent relation and let the package resolve it by name — don't subclass the hydrator. Keep unusual mapping logic (computed joins, aliased keys) in your query object so it's testable independently of the hydration call.
Testing. Assert with $this->assertQueryCount(1) around the request, and assert on $model->relationLoaded('author'). Cover the left join null case and one nested path explicitly; those are where regressions will surface as versions of Laravel change.
How can I help you explore Laravel packages today?