PHPackages                             atldays/laravel-eloquent-join-relation - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. atldays/laravel-eloquent-join-relation

ActiveLibrary[Database &amp; ORM](/categories/database)

atldays/laravel-eloquent-join-relation
======================================

Join related Eloquent models and hydrate them as loaded relations.

v1.0.0(1mo ago)00MITPHPPHP ^8.2CI passing

Since Apr 15Pushed 1mo agoCompare

[ Source](https://github.com/atldays/laravel-eloquent-join-relation)[ Packagist](https://packagist.org/packages/atldays/laravel-eloquent-join-relation)[ Docs](https://github.com/atldays/laravel-eloquent-join-relation)[ RSS](/packages/atldays-laravel-eloquent-join-relation/feed)WikiDiscussions master Synced 1w ago

READMEChangelog (1)Dependencies (6)Versions (3)Used By (0)

Laravel Eloquent Join Relation
==============================

[](#laravel-eloquent-join-relation)

[![Latest Version on Packagist](https://camo.githubusercontent.com/213edaa61e44d39d84d39093a841c3ec90c7e161438e69fe98ea78387a133282/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f61746c646179732f6c61726176656c2d656c6f7175656e742d6a6f696e2d72656c6174696f6e2e7376673f6c6f676f3d7061636b6167697374267374796c653d666f722d7468652d6261646765)](https://packagist.org/packages/atldays/laravel-eloquent-join-relation)[![Total Downloads](https://camo.githubusercontent.com/d0f6e9cb03d822cb5cd777df007e6c88f81c8cce5311c2292b8b14edcdc005ca/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f61746c646179732f6c61726176656c2d656c6f7175656e742d6a6f696e2d72656c6174696f6e2e7376673f7374796c653d666f722d7468652d626164676526636f6c6f723d626c7565)](https://packagist.org/packages/atldays/laravel-eloquent-join-relation)[![CI](https://camo.githubusercontent.com/7e4ea78b48972df54c0334c10c57530eff2d31bee216e7ee863e692a92626154/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f61746c646179732f6c61726176656c2d656c6f7175656e742d6a6f696e2d72656c6174696f6e2f63692e796d6c3f7374796c653d666f722d7468652d6261646765266c6162656c3d4349)](https://github.com/atldays/laravel-eloquent-join-relation/actions/workflows/ci.yml)[![License: MIT](https://camo.githubusercontent.com/7a1226d14a365d288bfe51ece915ee0c7e754a16faa51ff06436504de29b33b4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e7376673f7374796c653d666f722d7468652d6261646765)](LICENSE.md)

Laravel is great at working with relations, but as soon as a query becomes more complex and starts using `join`, loading relations through `with()` or lazy loading often leads to additional queries. The required data was already returned by the main SQL query.

This package solves that problem by letting you keep a single SQL query with `join` while still getting fully hydrated relations, as if they had been loaded through Eloquent in the usual way.

If you already joined a related table for filtering, sorting, or conditional checks, the package can build the relation directly from that joined data and set it on the model without touching the database again.

It is especially useful in complex queries that involve multiple related tables, where you still want to work with them afterward as normal nested Laravel relations.

Highlights
----------

[](#highlights)

- Hydrates `BelongsTo` and `HasOne` relations directly from data returned by `join`.
- Lets you work with joined data as normal Eloquent relations without extra SQL queries.
- Supports nested relation paths such as `author.team.organization`.
- Supports manual hydration for custom join scenarios through `hydrate`.
- Correctly returns `null` for missing records on `left join`.
- Fails explicitly when nested relation paths are joined out of order.
- Especially useful for complex queries that filter across multiple related tables.

Support
-------

[](#support)

- PHP: `8.2+`
- Laravel: `11.x`, `12.x`, `13.x`

Current boundaries
------------------

[](#current-boundaries)

- Supported relation types:
    - `BelongsTo`
    - `HasOne`
- Not supported yet:
    - `HasMany`
    - `BelongsToMany`
    - `MorphTo`, `MorphOne`, `MorphMany`
    - `HasOneThrough`
- Nested relation paths must be called in order:
    - first `author`
    - then `author.team`
    - then `author.team.organization`

Installation
------------

[](#installation)

```
composer require atldays/laravel-eloquent-join-relation
```

Basic usage
-----------

[](#basic-usage)

If you already join a related table, you can hydrate that relation without an extra query.

```
use Atldays\JoinRelation\HasJoinRelation;
use App\Models\Author;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Post extends Model
{
    use HasJoinRelation;

    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class, 'author_id');
    }
}
```

```
$posts = Post::query()
    ->select('posts.*')
    ->joinRelation(
        relation: 'author',
        columns: ['id', 'name', 'email'],
    )
    ->where('authors.active', true)
    ->get();

$post = $posts->first();

$post->author; // already hydrated, no extra query
```

The generated SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`name` as `join_author__name`,
    `authors`.`email` as `join_author__email`
from `posts`
inner join `authors`
    on `authors`.`id` = `posts`.`author_id`
where `authors`.`active` = 1
```

That means:

- one SQL query
- hydrated `$post->author`
- no follow-up query when you access the relation

Nested relation paths
---------------------

[](#nested-relation-paths)

For common `BelongsTo` chains, you can hydrate nested relations step by step.

```
$posts = Post::query()
    ->select('posts.*')
    ->joinRelation(
        relation: 'author',
        columns: ['id', 'team_id', 'name'],
    )
    ->joinRelation(
        relation: 'author.team',
        columns: ['id', 'organization_id', 'name'],
    )
    ->joinRelation(
        relation: 'author.team.organization',
        columns: ['id', 'name'],
    )
    ->where('posts.published', true)
    ->where('authors.active', true)
    ->where('teams.active', true)
    ->where('organizations.active', true)
    ->get();

$post = $posts->first();

$post->author;
$post->author->team;
$post->author->team->organization;
```

The generated SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`team_id` as `join_author__team_id`,
    `authors`.`name` as `join_author__name`,
    `teams`.`id` as `join_author_team__id`,
    `teams`.`organization_id` as `join_author_team__organization_id`,
    `teams`.`name` as `join_author_team__name`,
    `organizations`.`id` as `join_author_team_organization__id`,
    `organizations`.`name` as `join_author_team_organization__name`
from `posts`
inner join `authors`
    on `authors`.`id` = `posts`.`author_id`
inner join `teams`
    on `teams`.`id` = `authors`.`team_id`
inner join `organizations`
    on `organizations`.`id` = `teams`.`organization_id`
where `posts`.`published` = 1
  and `authors`.`active` = 1
  and `teams`.`active` = 1
  and `organizations`.`active` = 1
```

Important:

- `author` must be joined before `author.team`
- `author.team` must be joined before `author.team.organization`

If you skip an earlier level, the package throws an exception instead of silently falling back to lazy loading.

Left joins and nullable relations
---------------------------------

[](#left-joins-and-nullable-relations)

For optional relations, use `type: 'left'`.

```
$posts = Post::query()
    ->select('posts.*')
    ->joinRelation(
        relation: 'author',
        type: 'left',
        columns: ['id', 'name'],
    )
    ->get();

$post = $posts->first();

$post->author; // User model or null
```

The generated SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`name` as `join_author__name`
from `posts`
left join `authors`
    on `authors`.`id` = `posts`.`author_id`
```

The same applies to nested paths:

```
Post::query()
    ->select('posts.*')
    ->joinRelation(relation: 'author', columns: ['id', 'team_id', 'name'])
    ->joinRelation(
        relation: 'author.team',
        type: 'left',
        columns: ['id', 'organization_id', 'name'],
    )
    ->get();
```

The generated SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`team_id` as `join_author__team_id`,
    `authors`.`name` as `join_author__name`,
    `teams`.`id` as `join_author_team__id`,
    `teams`.`organization_id` as `join_author_team__organization_id`,
    `teams`.`name` as `join_author_team__name`
from `posts`
inner join `authors`
    on `authors`.`id` = `posts`.`author_id`
left join `teams`
    on `teams`.`id` = `authors`.`team_id`
```

If the joined `team` row is missing, `author->team` becomes `null`.

Manual hydrate mode
-------------------

[](#manual-hydrate-mode)

When the join is custom, or when you want to put the hydrated model somewhere non-standard, use `related + join + hydrate`.

This is especially useful when you already joined several tables and want to attach a model deeper in the tree yourself.

```
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause;

$posts = Post::query()
    ->select('posts.*')
    ->joinRelation(
        relation: 'author',
        columns: ['id', 'name', 'email'],
    )
    ->joinRelation(
        related: Profile::class,
        type: 'left',
        join: function (JoinClause $join): void {
            $join->on('authors.id', '=', 'profiles.user_id');
        },
        hydrate: function (Model $model, ?Profile $profile): void {
            $model->author?->setRelation('profile', $profile);
        },
        columns: ['id', 'user_id', 'bio'],
    )
    ->get();

$post = $posts->first();

$post->author->profile;
```

The generated SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`name` as `join_author__name`,
    `authors`.`email` as `join_author__email`,
    `profiles`.`id` as `join_profile__id`,
    `profiles`.`user_id` as `join_profile__user_id`,
    `profiles`.`bio` as `join_profile__bio`
from `posts`
inner join `authors`
    on `authors`.`id` = `posts`.`author_id`
left join `profiles`
    on `authors`.`id` = `profiles`.`user_id`
```

If the profile is missing on a `left join`, the callback receives `null`.

Advanced example
----------------

[](#advanced-example)

Here is the same style of query for a deeper chain where every level is required.

```
$posts = Post::query()
    ->select('posts.*')
    ->joinRelation(
        relation: 'author',
        type: 'inner',
        columns: ['id', 'team_id', 'name', 'active', 'deleted_at'],
    )
    ->joinRelation(
        relation: 'author.team',
        type: 'inner',
        columns: ['id', 'organization_id', 'name', 'active', 'deleted_at'],
    )
    ->joinRelation(
        relation: 'author.team.organization',
        type: 'inner',
        columns: ['id', 'name', 'active', 'deleted_at'],
    )
    ->where('posts.active', true)
    ->whereNull('posts.deleted_at')
    ->where('authors.active', true)
    ->whereNull('authors.deleted_at')
    ->where('teams.active', true)
    ->whereNull('teams.deleted_at')
    ->where('organizations.active', true)
    ->whereNull('organizations.deleted_at')
    ->get();
```

This gives you one SQL query and fully hydrated nested relations with no follow-up lazy-loading queries.

The resulting SQL will look roughly like this:

```
select
    `posts`.*,
    `authors`.`id` as `join_author__id`,
    `authors`.`team_id` as `join_author__team_id`,
    `authors`.`name` as `join_author__name`,
    `authors`.`active` as `join_author__active`,
    `authors`.`deleted_at` as `join_author__deleted_at`,
    `teams`.`id` as `join_author_team__id`,
    `teams`.`organization_id` as `join_author_team__organization_id`,
    `teams`.`name` as `join_author_team__name`,
    `teams`.`active` as `join_author_team__active`,
    `teams`.`deleted_at` as `join_author_team__deleted_at`,
    `organizations`.`id` as `join_author_team_organization__id`,
    `organizations`.`name` as `join_author_team_organization__name`,
    `organizations`.`active` as `join_author_team_organization__active`,
    `organizations`.`deleted_at` as `join_author_team_organization__deleted_at`
from `posts`
inner join `authors`
    on `authors`.`id` = `posts`.`author_id`
inner join `teams`
    on `teams`.`id` = `authors`.`team_id`
inner join `organizations`
    on `organizations`.`id` = `teams`.`organization_id`
where `posts`.`active` = 1
  and `posts`.`deleted_at` is null
  and `authors`.`active` = 1
  and `authors`.`deleted_at` is null
  and `teams`.`active` = 1
  and `teams`.`deleted_at` is null
  and `organizations`.`active` = 1
  and `organizations`.`deleted_at` is null
```

That is the main point of the package:

- you keep the join-heavy query you already need
- you still get normal nested Eloquent relations
- you do it with one SQL query instead of a join plus follow-up eager-load queries

What you save compared to `with()`
----------------------------------

[](#what-you-save-compared-to-with)

For queries that already depend on joins, a classic eager-loading approach often turns into:

1. one query for the root records
2. one query for `author`
3. one query for `author.team`
4. one query for `author.team.organization`

With `joinRelation(...)`, those joined records are hydrated from the same SQL result set, so relation access does not need those extra follow-up queries.

How it differs from `with()`
----------------------------

[](#how-it-differs-from-with)

`with()` is still great when you want classic eager loading.

This package is useful when:

- you already need SQL joins for filtering or sorting
- you want to avoid follow-up relation queries
- you still want to work with normal Eloquent relation objects

Testing status
--------------

[](#testing-status)

The package test suite covers:

- direct `BelongsTo`
- direct `HasOne`
- nested relation paths
- manual hydrate mode
- `left join => null`
- ordered nested path enforcement
- no lazy-loading fallback for hydrated relations

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance89

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

55d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/d3d0138ffb1733c52bcd1602597088817ed576f8f9489a3b58095cf5c84264ca?d=identicon)[atldays](/maintainers/atldays)

---

Top Contributors

[![atldays](https://avatars.githubusercontent.com/u/130153594?v=4)](https://github.com/atldays "atldays (15 commits)")

---

Tags

eloquentjoinjoinslaravelmodelphpquery-builderrelational-modellaraveleloquentjoinrelationatldays

###  Code Quality

TestsPHPUnit

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/atldays-laravel-eloquent-join-relation/health.svg)

```
[![Health](https://phpackages.com/badges/atldays-laravel-eloquent-join-relation/health.svg)](https://phpackages.com/packages/atldays-laravel-eloquent-join-relation)
```

###  Alternatives

[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k29.9M42](/packages/kirschbaum-development-eloquent-power-joins)[mongodb/laravel-mongodb

A MongoDB based Eloquent model and Query builder for Laravel

7.1k8.0M84](/packages/mongodb-laravel-mongodb)[reedware/laravel-relation-joins

Adds the ability to join on a relationship by name.

2121.2M16](/packages/reedware-laravel-relation-joins)[spatie/laravel-sluggable

Generate slugs when saving Eloquent models

1.5k12.4M291](/packages/spatie-laravel-sluggable)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[watson/validating

Eloquent model validating trait.

9743.4M53](/packages/watson-validating)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
