skip to content

In Eloquent, how do you declare a many-to-many relation whose pivot table stores a grade, and when do you add a custom pivot with using()?

level: middleimportance: must knowfreq 70%

answer

  1. belongsToMany on both sides
  2. course_student: alphabetical, singular
  3. pivot holds only the keys by default
  4. withPivot('grade'), withTimestamps(), as()
  5. using() with a class extending Pivot

basics

~20 s

Declare belongsToMany() on both models, backed by an alphabetically named pivot table such as course_student, and add withPivot('grade') to read the extra column. Add using() with a Pivot subclass when the pivot row needs casts or behaviour.

solid answer

~40 s

Both `Student` and `Course` declare `belongsToMany()`, and by convention the pivot table is the two singular snake_case names in alphabetical order, `course_student`, with `student_id` and `course_id`; extra arguments override the table and key names. Each related model gets a `pivot` attribute, but it holds only the keys unless you add `withPivot('grade', 'enrolled_on')`; `withTimestamps()` maintains the pivot's timestamps and `as('enrolment')` renames the attribute. When the enrolment needs casts, accessors or methods, I add `->using(Enrolment::class)` with a class extending `Pivot`, remembering that `Pivot` is non-incrementing by default and cannot use `SoftDeletes`. If the enrolment grows its own relations, I promote it to a full model.

go deeper

for a junior

Recall that many-to-many needs a pivot table and belongsToMany() on both models.

for a middle

Explain the table and key naming rules, why withPivot() is needed for extra columns, and what withTimestamps(), as() and using() each add.

for a senior

Judge when an enrolment-style pivot should become a full model with its own relations, and handle pivot quirks such as incrementing ids.

for a principal

Set a rule for when link tables stay pivots and when they become domain models, weighing simplicity against growing behaviour.

## Why a pivot table A student takes many courses and a course has many students. Neither table can hold the link in one column, so a **many-to-many** relation needs a third table, the **pivot** (intermediate) table, with one row per pairing. In Eloquent both ends are declared with `belongsToMany()`: ```php class Student extends Model { public function courses(): BelongsToMany { return $this->belongsToMany(Course::class) ->withPivot('grade', 'enrolled_on') ->withTimestamps() ->as('enrolment'); } } ``` `Course` declares the inverse with `belongsToMany(Student::class)`; the same table and key rules apply from the other side. ## The conventions - **Table name**: the two model names in snake_case, singular, sorted alphabetically and joined with `_`, so `Course` and `Student` give `course_student`. - **Pivot keys**: `student_id` for the declaring model and `course_id` for the related one, from each class's `getForeignKey()`. - Override them with `belongsToMany(Course::class, 'enrolments', 'student_id', 'course_id')`: related class, table, this model's key on the pivot, the related model's key on the pivot. Two further arguments name the parent and related key columns when they are not the primary keys. ## Extra columns on the pivot Each related model comes back with a **pivot attribute**, a small model holding the pivot row. By default it contains **only the two keys**. Everything else must be asked for: | Method | Effect | |---|---| | `withPivot('grade', 'enrolled_on')` | selects those pivot columns onto the pivot attribute | | `withTimestamps()` | maintains `created_at` and `updated_at` on the pivot; the table needs both columns | | `as('enrolment')` | renames the attribute from `pivot` to `enrolment` | | `using(Enrolment::class)` | hydrates the pivot row as your own class | Forgetting `withPivot('grade')` is the classic bug: `$course->pivot->grade` is simply `null` even though the column is filled. ## A custom pivot model with `using()` When the pivot row carries real meaning, such as an enrolment with a grade, give it its own class: ```php use Illuminate\Database\Eloquent\Relations\Pivot; class Enrolment extends Pivot { protected function casts(): array { return ['enrolled_on' => 'date']; } public function passed(): bool { return $this->grade !== null && $this->grade >= 50; } } ``` With `->using(Enrolment::class)`, `$course->enrolment` is an `Enrolment`, so casts, accessors and methods like `passed()` work on it. Points to know: - A custom pivot for a plain many-to-many extends `Pivot`; one for a polymorphic many-to-many extends `MorphPivot`. - `Pivot` sets `$incrementing = false`. If the pivot table has an auto-incrementing `id`, declare `#[Table(incrementing: true)]` or `public $incrementing = true` on the pivot class. - Pivot models may not use `SoftDeletes`; if enrolments must be soft deleted, promote the table to a full model. - `using()` does not replace `withPivot()`: the columns you want on the pivot still have to be listed. ## When to stop using a pivot If the pairing grows its own relations, such as submissions per enrolment, or is queried on its own, it is usually clearer to model it as a first-class `Enrolment` model with two `belongsTo` relations, and to reach the far side with `hasMany` or `hasManyThrough`. The pivot form stays best when the row is mainly a link with a few attributes. ## Declaration-time constraints Because the declaration returns a query builder, it can also fix a condition on the pivot for every use of that relation: - `wherePivot('status', 'active')` limits the relation to matching pivot rows when reading; - `withPivotValue('status', 'active')` both filters on that value and writes it when rows are attached through the relation; - `orderByPivot('enrolled_on')` orders the related models by a pivot column. A common pattern is two relations over one pivot, such as `courses()` and `activeCourses()`, where the second adds `wherePivot('status', 'active')`. The steps to set up a graded enrolment are therefore: 1. choose the pivot table name, conventional or explicit; 2. list every extra column with `withPivot()` and add `withTimestamps()` if the table has both timestamp columns; 3. add `as()` for a readable accessor and `using()` when the row needs casts or methods; 4. mirror the declaration on the inverse model so both sides agree.

  • Why is `$course->pivot->grade` null when the `course_student.grade` column is filled?
    The pivot attribute only carries the two key columns unless the relation lists extra ones. Add `->withPivot('grade')` to the `belongsToMany()` declaration, and the column is selected onto the pivot for every related model.
  • Your custom pivot table has an auto-incrementing `id`. What must the `Pivot` subclass declare?
    That its key increments: `#[Table(incrementing: true)]` in Laravel 13, or `public $incrementing = true`. The `Pivot` base class sets `$incrementing = false`, so without the change Eloquent treats the key as non-incrementing and does not read back the generated id.

A registrar's enrolment ledger links students and courses: neither the student file nor the course file lists the other, each ledger line pairs one student with one course and can note the grade. Reading the grade means asking for that column of the ledger line, not just the pairing.

saying these in an interview costs you the question

  • The pivot table for Student and Course is named students_courses
  • Pivot columns such as grade are loaded automatically
  • using() alone makes every pivot column available
  • A custom pivot model can use SoftDeletes like any model
  • Only one side of a many-to-many relation needs belongsToMany