laravelmastery
SnippetsArticlesSeriesNews

Eloquent, properly · Lesson 2 of 3

Eloquent, properly · Lesson 2

Many to many and pivot data

Connect itEloquentQueries

The invoice belongs to one team. A tag is different: it can sit on many invoices, and an invoice can carry many tags. The rows that join them live in invoice_tag. The tag and the invoice stay themselves. The fact of this tag on this invoice lives in the middle.

Video

PHP
Schema::create('invoice_tag', function (Blueprint $table): void {
    $table->foreignId('invoice_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->timestamp('tagged_at')->nullable();

    $table->primary(['invoice_id', 'tag_id']);
});

foreignId()->constrained() on both sides means a pivot row cannot point at a missing invoice or a missing tag. The composite primary key keeps one row per pair. tagged_at is the fact about this joining. If the pivot has data of its own, withPivot() is how it comes back on the model.

01 Name the pivot when it holds a fact

withTimestamps() keeps created_at and updated_at when the row is written. Leave them off the withPivot() list. Laravel adds those columns itself.

Example.phpPHP
class Example
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class)
            ->withPivot('tagged_at')
            ->withTimestamps();
    }
}

attach() writes the pivot row. Pass the extra column in the second argument. The first argument is the tag id, or a list of ids when you are adding several without removing any. Reading the fact back goes through pivot on the related model, and only after the tags relation has been loaded.

PHP
$invoice->tags()->attach($tag->id, [
    'tagged_at' => now(),
]);

$when = $invoice->tags->first()->pivot->tagged_at;

sync() replaces the set. Use it when the form is the full list of tags. Use attach() when you are adding one. sync() without the existing ids will detach tags the user did not mean to remove. Pass the pivot values as a nested array when the form is both the full list and the source of tagged_at. Ids missing from that array are detached. That is the whole of the call, so read the form before you sync().

02 Cast tagged_at on a pivot model

withPivot() returns the column. It does not cast it. tagged_at comes back as a string unless a pivot model says otherwise. Add the model when a screen compares the value to a Carbon date, or formats it. The relationship opts in with using(). The invoices and tags tables do not change.

InvoiceTag.phpPHP
class InvoiceTag extends Pivot
{
    protected function casts(): array
    {
        return [
            'tagged_at' => 'datetime',
        ];
    }
}

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)
        ->using(InvoiceTag::class)
        ->withPivot('tagged_at')
        ->withTimestamps();
}

Changing tagged_at on a tag the invoice already has is updateExistingPivot(), not another attach() and not a sync() of one id. attach() inserts. A second attach of the same pair fails the primary key, or adds a duplicate if the table allows it. sync() of the one id detaches every other tag. updateExistingPivot() writes the column on the row that already joins this invoice to this tag, and the rest of the set stays.

PHP
$invoice->tags()->updateExistingPivot($tag->id, [
    'tagged_at' => now(),
]);

detach($tag->id) deletes one pivot row. The pivot model is the cast. It is not a reason to stop using attach(), sync() and updateExistingPivot() on the relationship.

Recap

You can put tagged_at on the pivot, choose attach() or sync() on purpose, and cast the date on a pivot model.

  • withPivot() is how a fact about the joining comes back.
  • attach() adds one tag. sync() replaces the set.
  • sync() without the current ids detaches tags the form did not mention.
  • using(InvoiceTag::class) casts tagged_at. updateExistingPivot() changes it without replacing the set.

Check yourself · 3 questions

Question 1 of 3The form submits the full list of tags. Which write matches that?

The tag joins through a pivot. Next, every line a team has billed, without loading each invoice first.

← PreviousBelongs-to and whereBelongsTo

On this page

  • Name the pivot when it holds a fact
  • Cast tagged_at on a pivot model
  • 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.