laravelmastery
SnippetsArticlesSeriesNews

Eloquent, properly · Lesson 2 of 3

Eloquent, properly · Lesson 2

Has many through

Connect itEloquentQueries

The pivot joined a tag to many invoices. A team still needs every line it has billed. hasManyThrough() answers that without an intermediate collection in PHP. You do not load the invoices, loop, and load again. The relationship is one query.

Video

01 Walk two foreign keys

The first class is the far model, the one you want. The second is the model in the middle. Laravel follows team_id on invoices, then invoice_id on lines. The team is $this, so it is not an argument. Swap the two classes and the query looks for a key that is not there.

Team.phpPHP
class Team extends Model
{
    public function invoiceLines(): HasManyThrough
    {
        return $this->hasManyThrough(InvoiceLine::class, Invoice::class);
    }
}

The query is still a join. Constrain it when you do not want every line the team has ever written. The status column lives on invoices, so the where names that table. A where on amount needs no prefix while amount lives only on the lines. The moment both tables share a column name, qualify it.

PHP
$lines = $team->invoiceLines()
    ->where('invoices.status', 'issued')
    ->get();

If you need columns from the invoice as well as the line, a join you write yourself, or an eager load of invoices.lines, is clearer than stretching the through relationship. hasManyThrough() is for the far collection, not for a custom report. The line model that comes back is an InvoiceLine. The invoice number is not an attribute of that line unless you selected it, and selecting it in here is how the relationship turns into a report you will not recognise next month.

02 Count and sum without loading the lines

A team index that shows how many lines each team has billed does not need the lines. withCount() adds invoice_lines_count to each team and leaves the far rows in the database. The name is the relationship with _count on the end. Read that attribute in the view. Do not call invoiceLines and count the collection, or you have loaded every line to print an integer.

PHP
$teams = Team::query()
    ->withCount('invoiceLines')
    ->get();

$total = $team->invoiceLines()
    ->where('invoices.status', 'issued')
    ->sum('invoice_lines.amount');

sum() is the same join with an aggregate. Qualify invoice_lines.amount so a later amount column on invoices cannot make the total ambiguous. The result is a number of pence, matching the integer cast on the line. It is not a collection, and it does not give you descriptions. When the screen needs the rows, go back to get(). When it needs a total for issued invoices only, keep the where on invoices.status that the list already uses.

withCount() accepts a constraint when the badge should ignore drafts. A closure on the relationship applies the same invoices.status filter without a second query per team. Skipping the constraint counts every line the team has ever had, including drafts. That may be the badge you want. It is not the same number as the sum of issued lines, so do not label them as if they were.

The through relationship stays a poor place to reshape the line into an invoice. If the page is really a list of invoices with their lines nested, eager load invoices.lines and let the invoice resource, or the view, walk that tree. hasManyThrough() has done its job when the question was about the lines and the invoices were only the path.

Recap

You can reach a team's invoice lines without loading every invoice in PHP first, and you can count those lines without loading them either.

  • The first class is the far model. The second is the one in the middle.
  • The query follows team_id on invoices, then invoice_id on lines.
  • A report that needs invoice columns is clearer as an eager load or a join you write.
  • withCount('invoiceLines') and sum() answer the index without hydrating the lines.

Check yourself · 3 questions

Question 1 of 3You want every line a team has billed. What do you call?

The lines are reachable from the team. Next, name the constraints so a list cannot leak another tenant.

← PreviousMany to many and pivot data

On this page

  • Walk two foreign keys
  • Count and sum without loading the lines
  • Recap
  • Check yourself

Eloquent, properly

  • 1 Overview

Shape the data

  • 2 Models that match the domain
  • 3 Migrations you can still read next year
  • 4 Casts, enums and custom casters

Connect it

  • 5 One to many, without the surprises
  • 6 Belongs-to and whereBelongsTo
  • 7 Many to many and pivot data
  • 8 Has many through

Query it safely and fast

  • 9 Local and global scopes
  • 10 Eager loading on purpose
  • 11 Catching N+1 queries
  • 12 Indexes, select and chunking
  • 13 API resources without leaking attributes

Eloquent, properly

Lessons

  1. 01Overview

Shape the data

  1. 02Models that match the domain
  2. 03Migrations you can still read next year
  3. 04Casts, enums and custom casters

Connect it

  1. 05One to many, without the surprises
  2. 06Belongs-to and whereBelongsTo
  3. 07Many to many and pivot data
  4. 08Has many through

Query it safely and fast

  1. 09Local and global scopes
  2. 10Eager loading on purpose
  3. 11Catching N+1 queries
  4. 12Indexes, select and chunking
  5. 13API resources without leaking attributes

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.