DEV Community

Cover image for Laravel 13 Has Many Through Relationship: Explained with Example
Muhammad Yasir Amin
Muhammad Yasir Amin

Posted on Originally published at yasiramin.dev

Laravel 13 Has Many Through Relationship: Explained with Example

A has-many-through relationship in Laravel 13 lets a model reach distant records through an intermediate model. If a Country has many Users and a User has many Posts, Country::posts() can return all posts for the country with hasManyThrough(Post::class, User::class), with no manual joins.

What do you need before you start?

  • Laravel 13 on PHP 8.3 or newer
  • Three tables: countries, users (with country_id) and posts (with user_id)

What does the schema look like?

countries
    id, name

users
    id, country_id, name

posts
    id, user_id, title
Enter fullscreen mode Exit fullscreen mode
// users migration (excerpt)
$table->foreignId('country_id')->constrained();

// posts migration (excerpt)
$table->foreignId('user_id')->constrained();
Enter fullscreen mode Exit fullscreen mode

How do you define the relationship?

<?php

namespace App\Models;

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

class Country extends Model
{
    public function users()
    {
        return $this->hasMany(User::class);
    }

    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

The first argument is the final model you want. The second is the intermediate model. Laravel guesses the keys from the names: users.country_id and posts.user_id.

How do you use it?

$country = Country::find(1);

foreach ($country->posts as $post) {
    echo $post->title;
}

// add constraints like any relationship
$recent = $country->posts()->where('posts.created_at', '>=', now()->subWeek())->get();
Enter fullscreen mode Exit fullscreen mode

Qualify column names with the table (for example posts.created_at) because the generated query joins two tables and some columns exist in both.

How do you customize the keys?

Pass four more arguments when your column names do not follow convention.

return $this->hasManyThrough(
    Post::class,
    User::class,
    'country_id', // foreign key on users
    'author_id',  // foreign key on posts
    'id',         // local key on countries
    'id'          // local key on users
);
Enter fullscreen mode Exit fullscreen mode

What about has-one-through?

Use hasOneThrough() when you want one related record, such as the latest owner of a car through a mechanic. It takes the same arguments.

public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}
Enter fullscreen mode Exit fullscreen mode

How do you avoid N+1 queries?

$countries = Country::with('posts')->get();

$countries = Country::withCount('posts')->get();

foreach ($countries as $country) {
    echo $country->name.': '.$country->posts_count;
}
Enter fullscreen mode Exit fullscreen mode

How do you verify it works?

public function test_country_has_posts_through_users(): void
{
    $country = Country::factory()->create();
    $user = User::factory()->create(['country_id' => $country->id]);
    Post::factory()->count(3)->create(['user_id' => $user->id]);

    $this->assertCount(3, $country->posts);
}
Enter fullscreen mode Exit fullscreen mode

What are the common mistakes?

  • Wrong argument order: the final model comes first, the intermediate model second
  • Ambiguous column errors because the column name was not table-qualified
  • Trying to attach or save through the relationship. It is read-oriented. Create the posts from the user instead
  • Soft-deleted intermediate rows being ignored or included unexpectedly

FAQ

Can I go through more than two hops?

Not with one built-in relationship. Chain relationships, or write a query with joins.

Does hasManyThrough support eager loading?

Yes. Use with('posts') like any relationship.

Is it faster than looping users?

It runs one joined query instead of one query per user, so it is usually better, but check your indexes on the foreign keys.

References


Originally published on yasiramin.dev.

Top comments (0)