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
Mechanichas oneCarOwnerthrough aCar).
- HasManyThrough: Connects a model to multiple distant records via an intermediate model (e.g., A
Countryhas manyPostmodels throughUsermodels).
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'sSoftDeletestrait, 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_atorid) can occasionally conflict if you perform customselect()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 intermediateuser_idto 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 💬