Deep Data Fetching: Mastering hasManyThrough and hasOneThrough in Laravel

Mastering hasManyThrough and hasOneThrough in Laravel

Deep Data Fetching: Mastering hasManyThrough and hasOneThrough in Laravel

When working with relational databases in Laravel, data often spans across multiple tiers of relationships. A classic example is accessing a company's invoices through its users (Company -> User -> Invoice), or retrieving a project's deployment logs through its environments (Project -> Environment -> DeploymentLog).

Without intermediate relationship shortcuts, developers often load nested relationships (like $company->users->flatMap->invoices) or manually write verbose SQL JOIN queries. Laravel provides two powerful Eloquent relationship types to solve this: HasManyThrough and HasOneThrough.

In this tutorial, we will learn how HasManyThrough and HasOneThrough work, how to configure custom and non-standard foreign and local keys, and how to query deeply nested data cleanly without performance degradation.

What are Has-Through Relationships?

The "Has-Through" relationship provides a convenient shortcut for accessing distant relations via an intermediate model.

  • HasOneThrough: Connects a model to a single distant record via an intermediate model (e.g., A Mechanic has one CarOwner through a Car).
  • HasManyThrough: Connects a model to multiple distant records via an intermediate model (e.g., A Country has many Post models through User models).

Instead of executing nested loops in PHP, Eloquent generates a single, optimized SQL query with an internal JOIN on the intermediate table.

1. Standard HasManyThrough Example

Let's take a common e-commerce or SaaS architecture:

  • A Team has many Users.
  • A User has many Invoices.
  • A Team has many Invoices through Users.

Database Schema

Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->timestamps();
});

Schema::create('invoices', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->decimal('amount', 10, 2);
    $table->string('status');
    $table->timestamps();
});


Defining the Relationship on the Team Model

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

class Team extends Model
{
    protected $fillable = ['name'];

    /**
     * Get all of the invoices for the team via users.
     */
    public function invoices(): HasManyThrough
    {
        return $this->hasManyThrough(Invoice::class, User::class);
    }
}


Now, fetching all invoices for a team is as simple as accessing a direct relationship:

$team = Team::find(1);

// Direct collection access
$invoices = $team->invoices;

// Querying with constraints directly on the distant model
$paidInvoices = $team->invoices()->where('status', 'paid')->get();


2. Working with Custom and Non-Standard Foreign Keys

In legacy databases or specialized architectures, table primary keys and foreign keys do not always follow default Laravel conventions (such as id and {model}_id).

The hasManyThrough() and hasOneThrough() methods accept up to six parameters to customize every step of the key resolution chain:

return $this->hasManyThrough(
    FinalModel::class,        // 1. Target model to retrieve
    IntermediateModel::class, // 2. Intermediate model to step through
    'team_custom_id',         // 3. Foreign key on intermediate model (User)
    'user_custom_id',         // 4. Foreign key on target model (Invoice)
    'id',                     // 5. Local key on root model (Team)
    'uuid'                    // 6. Local key on intermediate model (User)
);


Real-World Custom Key Scenario

Suppose your application matches accounts across microservices where users are linked via a non-incrementing account_code rather than a standard integer id:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

class Organization extends Model
{
    /**
     * Get orders for the organization using custom codes.
     */
    public function orders(): HasManyThrough
    {
        return $this->hasManyThrough(
            Order::class,
            Member::class,
            'org_reference',     // Foreign key on 'members' table
            'member_code',       // Foreign key on 'orders' table
            'reference_id',      // Local key on 'organizations' table
            'code'               // Local key on 'members' table
        );
    }
}


3. Using HasOneThrough for Direct 1:1 Shortcuts

When the target model has a one-to-one relationship with the intermediate model, use hasOneThrough instead of hasManyThrough.

Consider an auto repair shop application:

  • A Mechanic is assigned to a Car.
  • A Car belongs to a Customer.
  • A Mechanic accesses the Customer through the Car.
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;

class Mechanic extends Model
{
    /**
     * Get the car owner for the mechanic.
     */
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(Customer::class, Car::class);
    }
}


Accessing the relation returns a single model instance rather than an Eloquent collection:

$mechanic = Mechanic::find(1);

// Returns single Customer instance
$owner = $mechanic->carOwner;
echo $owner->name;


Advanced Querying with Has-Through Relations

1. Eager Loading Distant Relations

Just like standard relationships, HasManyThrough relations can be eager loaded to avoid N+1 query problems:

$teams = Team::with('invoices')->get();


2. Filtering Parent Models Based on Distant Conditions

You can use whereHas() and whereDoesntHave() directly on has-through relations without chaining through the middle table:

// Fetch teams that have at least one overdue invoice > $1000
$overdueTeams = Team::whereHas('invoices', function ($query) {
    $query->where('status', 'overdue')
          ->where('amount', '>', 1000);
})->get();


3. Aggregations and Counts (withCount)

You can calculate counts and sums across distant models directly:

$teams = Team::withCount('invoices')
    ->withSum('invoices', 'amount')
    ->get();

foreach ($teams as $team) {
    echo "{$team->name} has {$team->invoices_count} invoices totaling \${$team->invoices_sum_amount}";
}


Important Things to Remember

  • Intermediate Soft Deletes: If your intermediate model (e.g., User) uses Laravel's SoftDeletes trait, Eloquent automatically excludes target records (Invoices) belonging to soft-deleted users in the generated SQL query.
  • Column Name Collisions: Because Eloquent joins the intermediate table, columns with identical names (such as created_at or id) can occasionally conflict if you perform custom select() statements. Always qualify columns with table names (e.g., invoices.id).
  • Read-Only Convenience: Has-through relationships are designed for fetching and querying. You cannot call $team->invoices()->create([...]) directly because Eloquent cannot guess which intermediate user_id to assign to the new invoice.

Conclusion

Eloquent's HasManyThrough and HasOneThrough relationships eliminate unnecessary nested loops and complex manual joins when navigating multi-tiered databases. By defining proper local and foreign keys, you can keep your query logic clean, expressive, and performant across deep data hierarchies.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬



Previous Post Next Post

Contact Form