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.
Eloquent, properly · Lesson 2
Many to many and pivot data
Video
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.
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.
$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.
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.
$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)caststagged_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?