Building Web Apps with Laravel Module 5 – Databases and Eloquent
Eloquent relationships, pivots and morph maps
Map Pathshala's centres, batches, students and fees as Eloquent relationships, keep facts on pivot rows, count in SQL, and give polymorphic types a morph map.
What you will learn
- Define hasMany, belongsTo, belongsToMany, hasManyThrough and morphMany relationships on Pathshala's tables
- Store and read extra columns on a pivot table with withPivot, withTimestamps and a custom pivot model
- Predict what attach, detach, sync, syncWithoutDetaching and toggle change and return
- Filter and count by related rows in SQL with whereHas, whereRelation, withCount, withSum and withExists
- Protect a polymorphic type column with Relation::enforceMorphMap and move the rows written before it
Before you start
What you’ll learn · Before you start
What you will learn
- Define hasMany, belongsTo, belongsToMany, hasManyThrough and morphMany relationships on Pathshala's tables
- Store and read extra columns on a pivot table with withPivot, withTimestamps and a custom pivot model
- Predict what attach, detach, sync, syncWithoutDetaching and toggle change and return
- Filter and count by related rows in SQL with whereHas, whereRelation, withCount, withSum and withExists
- Protect a polymorphic type column with Relation::enforceMorphMap and move the rows written before it
Before you start
On this page
Pathshala’s office asks the same six questions every week: which batches a centre runs, who joined Maths Morning and when, which guardians to call about that batch, who is overdue and by how much, what each student has paid, and what Aarav paid last. Each question crosses two to four tables, and every hand-written join is one more chance to join on the wrong column; asked through Eloquent relationships, the six answers below cost 10 SQL queries in all.
A relationship is one method on a model that names a foreign key or a pivot table, and Pathshala needs five kinds of
them. You will define each one, keep facts on a pivot row, change a batch’s members with attach, sync and
toggle, filter and count in SQL without loading rows, and give a polymorphic table a morph map before a class
rename can strand its rows.
One foreign key, read from either end
Pathshala Andheri runs two batches, and the batches table is where that fact lives: every batches row has a
centre_id column, and both Andheri batches hold the Andheri row’s id. Nothing in the centres table points at a
batch. One parent row with any number of child rows that carry its key is a
one-to-many relationship: A link where one parent row has any number of child rows, each child holding the parent's id in a foreign key column. In Eloquent the parent declares hasMany and the child belongsTo, both over that one column., and Eloquent reads it from either end: one method on each
model, the same foreign key: A column that holds the primary key of a row in another table, with a constraint that makes the database refuse a value matching no row there. foreignId('student_id')->constrained() creates one. More in “Migrations and schema design” underneath.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'city'])]
class Centre extends Model
{
// One centre runs many batches: every batches row carries a centre_id.
public function batches(): HasMany
{
return $this->hasMany(Batch::class);
}
} <?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
// status is 'pending', 'paid' or 'overdue'; amount_paise is whole paise.
#[Fillable(['student_id', 'due_on', 'amount_paise', 'status', 'paid_on'])]
class Instalment extends Model
{
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
// An instalment may be paid in parts, so it has many payments.
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
} hasMany on the parent guesses the child’s column from the parent model’s name in snake case plus _id, so
Centre::batches() reads batches.centre_id. belongsTo on the child guesses from its own method name plus _id,
so Instalment::student() reads instalments.student_id. A second argument replaces either guess. The migration
behind these models names every key <model>_id for exactly that reason, so no relationship on this page has to
name its foreign key.
The figure builds Pathshala’s tables into a graph, one key or pivot at a time, from the centre down to payments that sit two tables away from a student. Step through it once; later sections come back to its last four steps.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One centre, many batches. Every batches row carries a centre_id. Centre::batches() is a hasMany, so Eloquent takes the centre's id and asks for the batches whose centre_id matches. For Pathshala Andheri that is Maths Morning and Physics Evening.
- The same key, read upwards. Batch::centre() is a belongsTo on the very same column. The key sits on the batch, so the batch reads its own centre_id and fetches one centre by id. One key, two methods, one on each model.
- A pivot table joins two models. A student can join several batches and a batch has many students, so neither table can hold the other's id. The enrolments table keeps one row per pair, and belongsToMany() on Batch and on Student reads through it.
- The pivot row holds facts. Each enrolment also records the day the student joined and the fee plan. withPivot('joined_on', 'fee_plan_id') makes those columns readable, and the Enrolment pivot model belongs to a FeePlan like any other model.
- A second pivot, default name. Guardian and Student meet in guardian_student, both model names in the singular and in alphabetical order. That is the name Eloquent assumes when belongsToMany() is given no table name.
- Fees are two hasMany steps. Each instalment row holds a student_id and each payment row holds an instalment_id. Student::instalments() and Instalment::payments() are two one-to-many relationships, one after the other.
- Payments, two tables away. Student::payments() is a hasManyThrough over instalments. Its query joins payments to instalments and keeps the rows whose instalment belongs to the student, so withSum('payments', 'amount_paise') totalled all six students in one query.
The tour below migrates those tables, seeds 6 students, 18 instalments and 9 payments, and answers the office’s questions through the relationships, printing the number of queries each answer cost:
from session import run
# The Pathshala graph (nine models, one migration, one seeder) answers the office's weekly
# questions. The probe seeds the same small data set every run and prints each answer with
# the number of SQL queries it cost.
run(
"""
php artisan migrate --no-interaction
php tour_probe.php
""",
app=True,
overlay=("pathshala-graph", "tour"),
) Output
$ php artisan migrate --no-interaction INFO Running migrations. 2026_02_02_000000_create_pathshala_graph_tables ....................... DONE $ php tour_probe.php 1. Which batches does Pathshala Andheri run? Maths Morning Physics Evening (queries: 2) 2. Who is in Maths Morning, and when did each join? Aarav Mehta, joined 2026-01-05 Diya Kulkarni, joined 2026-01-05 Kabir Shah, joined 2026-01-19 Meera Iyer, joined 2026-02-02 (queries: 2) 3. Which guardians should the office call about Maths Morning? Anjali Kulkarni Lakshmi Iyer Pooja Mehta Sunil Shah Vikram Mehta (queries: 1) 4. Who has an overdue instalment, and how much is overdue? Diya Kulkarni: 2,500 rupees Kabir Shah: 5,000 rupees Rohan Desai: 2,500 rupees (queries: 1) 5. How much has each student paid so far? Aarav Mehta: 5,000 rupees Diya Kulkarni: 2,500 rupees Kabir Shah: 0 rupees Meera Iyer: 5,000 rupees Rohan Desai: 2,500 rupees Sana Qureshi: 5,000 rupees (queries: 1) 6. What were Aarav's latest and largest payments? latest: 1,500 rupees by bank at 2026-03-12 18:05:00 largest: 2,500 rupees by card at 2026-02-08 11:20:00 (queries: 3) Six answers, 10 queries in all. 7. The latest payment of every student, read one student at a time Aarav Mehta: 2026-03-12 18:05:00 Diya Kulkarni: 2026-02-09 10:10:00 Kabir Shah: no payment yet Meera Iyer: 2026-03-09 17:40:00 Rohan Desai: 2026-02-11 12:00:00 Sana Qureshi: 2026-03-08 15:30:00 (queries: 7)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tour_session.py
Question 1 cost 2 queries: one to find the centre and one for $centre->batches. That read is a dynamic
property: its first access runs the query and keeps the collection on the model. $centre->batches(), the method,
returns the relationship as a query builder instead, so conditions can be added before anything runs. The next
session reads both forms, and then gives the instalments table a parent method with the wrong name:
from session import run
# The foreign key each one-to-many method guesses, a relationship read as a property and as a
# method, and what a belongsTo method named after something other than its parent reads.
run(
"""
php artisan migrate --no-interaction
php key_guess_probe.php
""",
app=True,
overlay=("pathshala-graph", "key-guess"),
) Output
$ php artisan migrate --no-interaction INFO Running migrations. 2026_02_02_000000_create_pathshala_graph_tables ....................... DONE $ php key_guess_probe.php Centre::batches() reads batches.centre_id Instalment::student() reads instalments.student_id $centre->batches, read twice: 1 query, 2 models in an Eloquent Collection $centre->batches()->where(...)->get(): 1 query, Physics Evening learner() guesses the column learner_id Its parent, with the default settings: NULL With preventAccessingMissingAttributes(): MissingAttributeException The attribute [learner_id] either does not exist or was not retrieved for model [RenamedInstalment].
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 key_guess_session.py
The property cost 1 query for two reads and returned an Eloquent Collection of 2 batches; the method with a
where() cost 1 query of its own and kept only Physics Evening, the batch starting on or after 2026-01-10.
A belongsTo method's name is part of its key
learner() on the same instalments table guessed learner_id, a column the rows do not have, and the
relationship returned NULL without a word. With preventAccessingMissingAttributes(), one of the guards that
Model::shouldBeStrict() turns on in the models lesson, the same read
threw MissingAttributeException and named learner_id. Name the method after its parent model, or give the key:
belongsTo(Student::class, 'student_id').
Checkpoint
Check: One foreign key, two methods
Many-to-many needs a pivot table, and the pivot keeps facts
Meera Iyer is in two batches: Maths Morning, which she joined on 2026-02-02 on the Term plan, and Chemistry
Weekend, which she joined on 2026-02-07 on the Monthly plan. A batch_id column on students would hold only one
batch, and the joining day and the fee plan belong to neither the student nor the batch but to the pair. A
many-to-many relationship: A link where rows on both sides may have many partners, such as students and batches. Each pair is one row of a pivot table, and Eloquent reads it with belongsToMany on both models. keeps each pair as a row of a third table, the
pivot table: The table that stores one row per pair of a many-to-many relationship: both foreign keys, plus any facts about the pair, such as the day a student joined a batch.. Pathshala’s is enrolments, and its four rows for Maths Morning are the
four lines question 2 printed:
| batch_id | student_id | joined_on | fee_plan_id |
|---|---|---|---|
| 1 (Maths Morning) | 1 (Aarav) | 2026-01-05 |
1 (Monthly) |
| 1 | 2 (Diya) | 2026-01-05 |
1 (Monthly) |
| 1 | 3 (Kabir) | 2026-01-19 |
1 (Monthly) |
| 1 | 4 (Meera) | 2026-02-02 |
2 (Term) |
Both models declare the relationship, each from its own side. Batch::students() and Student::batches() read
the same rows:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['centre_id', 'name', 'start_on'])]
class Batch extends Model
{
// The inverse of Centre::batches(): this row holds the centre_id.
public function centre(): BelongsTo
{
return $this->belongsTo(Centre::class);
}
// Many students per batch, many batches per student. The enrolments table joins them
// and keeps two facts of its own: the day the student joined and the fee plan.
public function students(): BelongsToMany
{
return $this->belongsToMany(Student::class, 'enrolments')
->using(Enrolment::class)
->as('enrolment')
->withPivot('joined_on', 'fee_plan_id')
->withTimestamps();
}
public function homework(): HasMany
{
return $this->hasMany(Homework::class);
}
} <?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
// One row of the enrolments table, as a class of its own. Batch::students() and
// Student::batches() return it through ->using(Enrolment::class), so a pivot row can
// have methods and relationships like any model.
class Enrolment extends Pivot
{
protected $table = 'enrolments';
// The enrolments table has its own auto-increment id column.
public $incrementing = true;
public function feePlan(): BelongsTo
{
return $this->belongsTo(FeePlan::class);
}
} belongsToMany(Student::class, 'enrolments') names its table because enrolments is not the default. The default
is both model names in the singular, in alphabetical order, which is why Student::guardians() reads
guardian_student with no table name given. Four more calls decide what the pivot row gives back:
withPivot('joined_on', 'fee_plan_id')adds those two columns to the pivot object Eloquent puts on each student; by default that object holds only the two keys.withTimestamps()keeps the pivot’screated_atandupdated_atfilled, as Eloquent does for a model’s own timestamps.as('enrolment')renames that object frompivottoenrolment, so the tour reads$student->enrolment->joined_on.using(Enrolment::class)makes each pivot row an instance of a pivot model: A class that extends Eloquent's Pivot and is named with using() on a belongsToMany relationship, so each pivot row becomes an object with methods and relationships of its own., a class that extendsPivotand can have methods and relationships of its own.
Enrolment sets $incrementing = true because the enrolments table has its own auto-increment id, and a plain
Pivot has that flag off. The documentation asks a custom pivot model with such a key to say so with the
#[Table(incrementing: true)] attribute; the property used here sets the same flag. Its feePlan() relationship is
how the next section’s session reads a student’s plan through the pivot.
Checkpoint
Check: Pivot tables and pivot data
attach, sync and toggle each change the pivot differently
Maths Morning’s list changes several times a term: a student joins late, another moves to an evening batch, and the
office’s admin form saves the whole list after an edit. Each of those is a different call on $batch->students().
The session below makes five of them in a row on the seeded batch, printing the members after each call and the
arrays the calls returned:
from session import run
# Every write a many-to-many relationship offers, on the Maths Morning batch: attach,
# detach, sync, syncWithoutDetaching and toggle, with the value each call returned.
run(
"""
php artisan migrate --no-interaction
php writes_probe.php
""",
app=True,
overlay=("pathshala-graph", "pivot-writes"),
) Output
$ php artisan migrate --no-interaction INFO Running migrations. 2026_02_02_000000_create_pathshala_graph_tables ....................... DONE $ php writes_probe.php Start: P-001 P-002 P-003 P-004 attach(P-005): P-001 P-002 P-003 P-004 P-005 attach(P-005) again: refused, UniqueConstraintViolationException detach(P-003): P-001 P-002 P-004 P-005 sync(P-001 P-002 P-006): P-001 P-002 P-006 returned attached [P-006], detached [P-004 P-005], updated [P-002] syncWithoutDetaching(P-003): P-001 P-002 P-003 P-006 returned attached [P-003], detached [] toggle(P-001 P-005): P-002 P-003 P-005 P-006 returned attached [P-005], detached [P-001] P-002's plan, read through the pivot model: Term On the Term plan in Maths Morning: P-002 instalments()->create() on P-006 wrote student_id = 6 instalment()->associate() wrote instalment_id = 19
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pivot_writes_session.py
The figure replays that run, one call a step. Watch which roll numbers each call adds and which it removes.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Four students to start. The seeder enrolled P-001 to P-004 in Maths Morning, one enrolments row each with joined_on and fee_plan_id.
- attach() inserts a row. attach(P-005) inserted one enrolments row with the joined_on and fee plan it was given. A second attach of P-005 was refused with UniqueConstraintViolationException, because the migration made (batch_id, student_id) unique.
- detach() deletes a row. detach(P-003) deleted Kabir's enrolments row and nothing else. Kabir's student row and instalments stay; only the link between him and the batch is gone.
- sync() makes the list the set. sync() was given P-001, P-002 and P-006. It attached P-006, detached P-004 and P-005 because they were missing from the list, and updated P-002, whose fee plan changed to Term. It returned all three lists.
- Adding without removing. syncWithoutDetaching(P-003) attached P-003 again and detached nobody, so the three members that sync() left all stayed.
- toggle() flips each id. toggle(P-001, P-005) detached P-001, who was a member, and attached P-005, who was not. Each id in the list changes state; ids left out keep theirs.
| Call | What it does to the pivot table | What it returns |
|---|---|---|
attach($ids, $columns) |
Inserts one row per id, with the pivot columns given | Nothing |
detach($ids) |
Deletes the rows of those ids; with no ids, every row of the parent | The number of rows deleted |
sync($list) |
Inserts the missing ids, deletes the ids the list leaves out, updates changed pivot columns | The attached, detached and updated ids |
syncWithoutDetaching($list) |
Inserts the missing ids and updates changed ones; deletes nothing | The same arrays, detached always empty |
toggle($ids) |
Detaches each id that is attached and attaches each one that is not | The attached and detached ids |
attach never looks for an existing row first. The second attach of P-005 was stopped by the database, with
UniqueConstraintViolationException, only because the migration made (batch_id, student_id) unique. sync
treats its list as the whole membership: P-004 and P-005 were detached because the list left them out, and their
joined_on and fee plan went with their rows. The last lines of the session read the moved student’s plan through
the pivot model ($diya->enrolment->feePlan->name gave Term) and filtered on a pivot column with wherePivot.
A partial list is a delete order
An admin page that lists 20 students a page and saves with sync() of the ids on that page detaches every member
on the other pages. Send sync() only a list that is always complete; save a page’s changes with attach and
detach, or syncWithoutDetaching for additions. Each call also has an OrFail variant, such as syncOrFail,
that runs it in a database transaction, so a failure halfway leaves the list as it was.
One-to-many writes fill the foreign key as well. $sana->instalments()->create([...]) wrote student_id = 6
although the array never named it, and $payment->instalment()->associate($instalment) set instalment_id = 19
on the payment before save().
Checkpoint
Check: Attach, sync and toggle
Payments two tables away, and one payment out of many
Question 5 asked what each student has paid. A payment row holds an instalment_id, not a student_id, and only
the instalment knows its student. A has-many-through relationship: A relationship that reaches rows two tables away through an intermediate model: Student::payments() joins payments to instalments and keeps the rows whose instalment belongs to the student. reads across
that middle table: Student::payments() is hasManyThrough(Payment::class, Instalment::class), and its query joins
payments to instalments and keeps the rows whose instalment belongs to the student. That is how
withSum('payments', 'amount_paise') totalled all six students in 1 query in the tour.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['name', 'roll_no'])]
class Student extends Model
{
// The same enrolments table as Batch::students(), read from the other side.
public function batches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'enrolments')
->using(Enrolment::class)
->as('enrolment')
->withPivot('joined_on', 'fee_plan_id')
->withTimestamps();
}
// No table name given: Eloquent joins the two model names, singular and in
// alphabetical order, and reads the guardian_student table.
public function guardians(): BelongsToMany
{
return $this->belongsToMany(Guardian::class)->withPivot('relation');
}
public function instalments(): HasMany
{
return $this->hasMany(Instalment::class);
}
// Payments sit two tables away: students -> instalments -> payments.
public function payments(): HasManyThrough
{
return $this->hasManyThrough(Payment::class, Instalment::class);
}
// One row out of many, picked in SQL: one() turns payments() into a has-one-through,
// and latestOfMany() keeps the newest payment by paid_at ...
public function latestPayment(): HasOneThrough
{
return $this->payments()->one()->latestOfMany('paid_at');
}
// ... and ofMany() keeps the largest one by amount.
public function largestPayment(): HasOneThrough
{
return $this->payments()->one()->ofMany('amount_paise', 'max');
}
} Question 6 wanted one payment out of many: Aarav’s latest and his largest. Loading every payment and sorting it in
PHP is fine for three rows and wasteful for three thousand. A one-of-many relationship: A has-one relationship that picks a single row out of many related rows in SQL, such as the latest payment with latestOfMany() or the largest with ofMany('amount_paise', 'max').
picks the row in SQL instead. one() turns payments() into a has-one-through, latestOfMany('paid_at') keeps the
payment with the greatest paid_at, and ofMany('amount_paise', 'max') keeps the one with the largest amount.
Aarav’s three payments, and what each method picked:
| Payment | Paid at | Method | Amount | Picked by |
|---|---|---|---|---|
| 1 | 2026-02-08 11:20:00 |
card | 2,500 rupees | ofMany('amount_paise', 'max') |
| 2 | 2026-03-05 09:45:00 |
cash | 1,000 rupees | neither |
| 3 | 2026-03-12 18:05:00 |
bank | 1,500 rupees | latestOfMany('paid_at') |
The tour’s answer to question 6 cost 3 queries: one for Aarav and one for each relationship. Question 7 read
latestPayment inside a loop over all six students and cost 7 queries, 1 for the students and 1 for each of them.
The relationship is right there; the loop is what multiplies it, and
the eager loading lesson loads it for every student at once.
Key idea
A relationship method says which rows belong together. withSum, latestOfMany and ofMany then make the
database do the adding and the picking, so PHP receives one number or one row instead of every related row.
Checkpoint
Check: Through relations and one of many
Filter and count by related rows without loading them
Question 4 needed the students with an overdue instalment and each one’s overdue total: three students, in 1 query. Loading all six students with all 18 instalments and filtering in PHP gives the same three names after reading every row. An existence query: A query that keeps only the parent rows that have, or lack, matching related rows. Eloquent writes it with has, doesntHave, whereHas or whereRelation as a subquery inside the parent's one statement. puts the condition in SQL instead: the database checks the related rows and returns only the parents that match. The session runs six of them and then prints the SQL four of them send:
from session import run
# Filtering and counting by related rows in SQL: has, doesntHave, whereRelation, whereHas,
# withCount, withSum and withExists, with the query count and the SQL two of them sent.
run(
"""
php artisan migrate --no-interaction
php related_probe.php
""",
app=True,
overlay=("pathshala-graph", "related-queries"),
) Output
$ php artisan migrate --no-interaction
INFO Running migrations.
2026_02_02_000000_create_pathshala_graph_tables ....................... DONE
$ php related_probe.php
has('payments', '>=', 2): Aarav Mehta, Meera Iyer, Sana Qureshi
doesntHave('payments'): Kabir Shah
whereRelation(overdue): Diya Kulkarni, Kabir Shah, Rohan Desai
whereHas(overdue, due < Mar): Kabir Shah
withCount('students') per batch:
Maths Morning: 4
Physics Evening: 2
Chemistry Weekend: 2
withSum('instalments as due_paise', 'amount_paise') and withExists(overdue):
P-001: due_paise 750000, has_overdue false
P-002: due_paise 750000, has_overdue true
P-003: due_paise 750000, has_overdue true
P-004: due_paise 750000, has_overdue false
P-005: due_paise 750000, has_overdue true
P-006: due_paise 750000, has_overdue false
Queries for the six lists above: 6
SQL of whereRelation:
select * from "students" where exists (select * from "instalments" where "students"."id" = "instalments"."student_id" and "status" = ?)
bindings: ["overdue"]
SQL of has('payments', '>=', 2):
select * from "students" where (select count(*) from "payments" inner join "instalments" on "instalments"."id" = "payments"."instalment_id" where "students"."id" = "instalments"."student_id") >= 2
SQL of doesntHave('payments'):
select * from "students" where not exists (select * from "payments" inner join "instalments" on "instalments"."id" = "payments"."instalment_id" where "students"."id" = "instalments"."student_id")
SQL of withCount:
select "batches".*, (select count(*) from "students" inner join "enrolments" on "students"."id" = "enrolments"."student_id" where "batches"."id" = "enrolments"."batch_id") as "students_count" from "batches"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 related_queries_session.py
Six lists came back from 6 queries, one each, with no instalment or payment loaded into PHP. The printed SQL shows
the two shapes Eloquent uses. A filter becomes a subquery in the where clause: exists (…) for whereRelation,
not exists (…) for doesntHave, and a count(*) subquery compared with 2 for has('payments', '>=', 2). A
count or a sum becomes a subquery in the select list, which withCount('students') names students_count.
| Method | What it adds | Where the SQL goes |
|---|---|---|
has, doesntHave |
Keeps parents with (or without) enough related rows | where |
whereHas, whereRelation |
Keeps parents with a related row that matches conditions | where |
withCount('students') |
A students_count attribute |
select |
withSum('payments', 'amount_paise') |
A payments_sum_amount_paise attribute, or the name after as |
select |
withExists(...) |
A true or false attribute, cast to a boolean by Eloquent | select |
The documentation names the aggregate attributes {relation}_{function}_{column}; as due_paise replaced that
name in the session, and withExists cast has_overdue to true or false by itself. whereRelation is the
short form of whereHas for one column; the closure form takes any conditions, which is how the whereHas line kept
only Kabir, the one student with an overdue instalment due before 2026-03-01.
Count in the query, not in the loop
$batch->students->count() loads every student of the batch to count them in PHP. withCount('students') asks
for the three batches’ counts in the same query that reads the batches, and loads no student at all.
Checkpoint
Check: Filtering and counting in SQL
A polymorphic relation stores a type, so pin it with a morph map
Students ask questions under homework, guardians under notices, and Pathshala keeps every comment in one comments
table. One row cannot carry a foreign key to two tables, so a
polymorphic relationship: A relationship in which a child row can belong to parents of different models. The child stores the parent's id and a type column that names the parent's model, such as commentable_id and commentable_type. stores two columns instead: commentable_id, the
parent’s id, and commentable_type, which says whose id it is. The migration’s morphs('commentable') adds both
columns and an index over the pair; Homework::comments() and Notice::comments() are
morphMany(Comment::class, 'commentable'), and Comment::commentable() is a morphTo() that reads the type to
decide which model to load.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable(['body'])]
class Comment extends Model
{
// The parent is a Homework or a Notice: commentable_type says which table,
// commentable_id which row of it.
public function commentable(): MorphTo
{
return $this->morphTo();
}
} The type column is where the trouble starts. With no setting, Eloquent stores the full class name there, so the data depends on what the PHP classes are called. The session stores two comments that way, imitates a rename, adds a morph map: A list of short aliases for the models of polymorphic relationships, set with Relation::morphMap() or Relation::enforceMorphMap(), so type columns store an alias such as notice instead of a class name. and finally moves the old rows:
from session import run
# One comments table for homework and notices: the type column the polymorphic relation
# writes, with and without Relation::enforceMorphMap().
run(
"""
php artisan migrate --no-interaction
php morph_map_probe.php
""",
app=True,
overlay=("pathshala-graph", "morph-map"),
) Output
$ php artisan migrate --no-interaction INFO Running migrations. 2026_02_02_000000_create_pathshala_graph_tables ....................... DONE $ php morph_map_probe.php 1. No morph map: the full class name is stored comment 1: commentable_type = App\Models\Homework, commentable_id = 1 comment 2: commentable_type = App\Models\Notice, commentable_id = 1 2. If Notice were renamed Circular, comment 2's parent: fails, Error: Class "App\Models\Circular" not found 3. enforceMorphMap(): a new comment stores the alias comment 1: commentable_type = App\Models\Homework, commentable_id = 1 comment 2: commentable_type = App\Models\Notice, commentable_id = 1 comment 3: commentable_type = notice, commentable_id = 1 4. Comment 3's parent, through the alias: Fee counter hours Comment 1's parent, still a class name: fails, ClassMorphViolationException: No morph map defined for model [App\Models\Homework]. A Batch as a parent: refused, ClassMorphViolationException: No morph map defined for model [App\Models\Batch]. 5. After moving 2 old rows to the aliases comment 1: commentable_type = homework, commentable_id = 1 comment 2: commentable_type = notice, commentable_id = 1 comment 3: commentable_type = notice, commentable_id = 1 Comment 1's parent now: Quadratic equations, set 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 morph_map_session.py
The figure follows the comments table through the same five steps. The fourth is the one a deploy runs into.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The class name in the row. With no morph map, the two seeded comments stored App\Models\Homework and App\Models\Notice in commentable_type, next to commentable_id 1. The data now depends on what the PHP classes are called.
- A rename breaks old rows. The probe made comment 2 name a class that does not exist, as a rename of Notice would leave it. Reading its parent failed with Error, Class not found. No query can repair that; only an update of the rows can.
- New rows store the alias. After Relation::enforceMorphMap() with the aliases homework and notice, the next comment stored notice as its type. Its parent read back through the alias.
- Old rows are refused. Comment 1 still held App\Models\Homework, which is not an alias in the map, so reading its parent threw ClassMorphViolationException. A Batch as a parent was refused the same way before anything was written.
- Move the rows in the same deploy. Two UPDATE statements rewrote the class names to homework and notice, and comment 1 read its parent again. Ship the map and the row update together, or old rows fail from the first request.
Step 2 shows why the class name is a liability: after a rename the stored class no longer exists, and reading the
parent failed with Class "App\Models\Circular" not found. A morph map stores short aliases, homework and
notice, and Relation::enforceMorphMap() (called in AppServiceProvider::boot() in a real application) also
refuses any model without an alias: the Batch parent was turned away with ClassMorphViolationException before a row
was written. Step 4 is the trap. Comment 1 still held App\Models\Homework, which is a class and not an alias of the
map, so with the map enforced its parent could not be read at all. The documentation says that every *_type value
holding a class name must be converted when a map is added to an existing application; the session’s two UPDATE
statements did that, and comment 1 read its parent again.
Key idea
Give a polymorphic relation its morph map before the first row is written. On a table that already has rows, the enforced map and the conversion of the old type values must reach production together.
Checkpoint
Check: Polymorphic types and morph maps
Practise: the office’s call list from the relationships
Work in a copy of your Pathshala project, with this page’s pathshala-graph folder copied in. The seeder that the
sessions above ran is the data the steps refer to:
<?php
namespace Database\Seeders;
use App\Models\Centre;
use App\Models\FeePlan;
use App\Models\Guardian;
use App\Models\Notice;
use App\Models\Student;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
// The same small Pathshala on every run: 2 centres, 3 batches, 6 students, 7 guardians,
// 18 instalments and 9 payments. People, phone numbers and amounts are invented.
class PathshalaGraphSeeder extends Seeder
{
public function run(): void
{
Carbon::setTestNow('2026-03-20 10:00:00');
$monthly = FeePlan::create(['name' => 'Monthly', 'amount_paise' => 250000]);
$term = FeePlan::create(['name' => 'Term', 'amount_paise' => 650000]);
$andheri = Centre::create(['name' => 'Pathshala Andheri', 'city' => 'Mumbai']);
$kothrud = Centre::create(['name' => 'Pathshala Kothrud', 'city' => 'Pune']);
// create() through a hasMany relation fills centre_id by itself.
$maths = $andheri->batches()->create(['name' => 'Maths Morning', 'start_on' => '2026-01-05']);
$physics = $andheri->batches()->create(['name' => 'Physics Evening', 'start_on' => '2026-01-12']);
$chemistry = $kothrud->batches()->create(['name' => 'Chemistry Weekend', 'start_on' => '2026-02-07']);
$names = ['Aarav Mehta', 'Diya Kulkarni', 'Kabir Shah', 'Meera Iyer', 'Rohan Desai', 'Sana Qureshi'];
$students = [];
foreach ($names as $i => $name) {
$students[] = Student::create(['name' => $name, 'roll_no' => sprintf('P-%03d', $i + 1)]);
}
[$aarav, $diya, $kabir, $meera, $rohan, $sana] = $students;
// attach() writes one enrolments row per student, with the pivot's own columns.
$maths->students()->attach([
$aarav->id => ['joined_on' => '2026-01-05', 'fee_plan_id' => $monthly->id],
$diya->id => ['joined_on' => '2026-01-05', 'fee_plan_id' => $monthly->id],
$kabir->id => ['joined_on' => '2026-01-19', 'fee_plan_id' => $monthly->id],
$meera->id => ['joined_on' => '2026-02-02', 'fee_plan_id' => $term->id],
]);
$physics->students()->attach([
$aarav->id => ['joined_on' => '2026-01-12', 'fee_plan_id' => $term->id],
$rohan->id => ['joined_on' => '2026-01-12', 'fee_plan_id' => $monthly->id],
]);
$chemistry->students()->attach([
$meera->id => ['joined_on' => '2026-02-07', 'fee_plan_id' => $monthly->id],
$sana->id => ['joined_on' => '2026-02-07', 'fee_plan_id' => $term->id],
]);
$guardians = [
['Vikram Mehta', $aarav, 'father'],
['Pooja Mehta', $aarav, 'mother'],
['Anjali Kulkarni', $diya, 'mother'],
['Sunil Shah', $kabir, 'father'],
['Lakshmi Iyer', $meera, 'mother'],
['Neha Desai', $rohan, 'mother'],
['Farah Qureshi', $sana, 'mother'],
];
foreach ($guardians as $i => [$name, $student, $relation]) {
$guardian = Guardian::create(['name' => $name, 'phone' => sprintf('+91 00000 %05d', 101 + $i)]);
$guardian->students()->attach($student->id, ['relation' => $relation]);
}
// Three instalments a student. A paid one lists its payments: [paise, paid at, method].
$plan = [
'Aarav Mehta' => [
['2026-02-10', 'paid', '2026-02-08', [[250000, '2026-02-08 11:20:00', 'card']]],
['2026-03-10', 'paid', '2026-03-12', [[100000, '2026-03-05 09:45:00', 'cash'], [150000, '2026-03-12 18:05:00', 'bank']]],
['2026-04-10', 'pending', null, []],
],
'Diya Kulkarni' => [
['2026-02-10', 'paid', '2026-02-09', [[250000, '2026-02-09 10:10:00', 'cash']]],
['2026-03-10', 'overdue', null, []],
['2026-04-10', 'pending', null, []],
],
'Kabir Shah' => [
['2026-02-10', 'overdue', null, []],
['2026-03-10', 'overdue', null, []],
['2026-04-10', 'pending', null, []],
],
'Meera Iyer' => [
['2026-02-10', 'paid', '2026-02-10', [[250000, '2026-02-10 08:30:00', 'bank']]],
['2026-03-10', 'paid', '2026-03-09', [[250000, '2026-03-09 17:40:00', 'card']]],
['2026-04-10', 'pending', null, []],
],
'Rohan Desai' => [
['2026-02-10', 'paid', '2026-02-11', [[250000, '2026-02-11 12:00:00', 'cash']]],
['2026-03-10', 'overdue', null, []],
['2026-04-10', 'pending', null, []],
],
'Sana Qureshi' => [
['2026-02-10', 'paid', '2026-02-07', [[250000, '2026-02-07 16:15:00', 'card']]],
['2026-03-10', 'paid', '2026-03-08', [[250000, '2026-03-08 15:30:00', 'card']]],
['2026-04-10', 'pending', null, []],
],
];
foreach ($students as $student) {
foreach ($plan[$student->name] as [$dueOn, $status, $paidOn, $payments]) {
$instalment = $student->instalments()->create([
'due_on' => $dueOn,
'amount_paise' => 250000,
'status' => $status,
'paid_on' => $paidOn,
]);
foreach ($payments as [$paise, $paidAt, $method]) {
$instalment->payments()->create(['amount_paise' => $paise, 'paid_at' => $paidAt, 'method' => $method]);
}
}
}
// Homework and a notice, each with a comment in the one comments table.
$sheet = $maths->homework()->create(['title' => 'Quadratic equations, set 3', 'due_on' => '2026-03-23']);
$maths->homework()->create(['title' => 'Probability worksheet', 'due_on' => '2026-03-30']);
$notice = Notice::create(['title' => 'Fee counter hours', 'body' => 'Open 09:00 to 13:00 on Saturdays.']);
$sheet->comments()->create(['body' => 'Is question 7 part of the set?']);
$notice->comments()->create(['body' => 'Can I pay by card at the counter?']);
}
} - Run
php artisan migrate --pretendto print the CREATE TABLE statements without running them, and paste them into the SQL to ER diagram tool. Compare its drawing with the figure in the first section, then runphp artisan migrateandphp artisan db:seed --class=PathshalaGraphSeeder. - Add
Centre::homework()as a has-many-through over batches. In Tinker, Pathshala Andheri should list the two homework titles of Maths Morning, and Pathshala Kothrud none. - Build the office’s call list: the guardians who have at least one student with an overdue instalment, using a dotted relation name the way question 3 of the tour did. Check your three names against the guardian list of the seeder and the overdue students of question 4.
- Add
withCount(['students as overdue_children' => ...])to the same query, with the overdue condition in the closure, and confirm withDB::enableQueryLog()that the list with its counts still costs 1 query. - Write a second admin action that adds one student to a batch with
syncWithoutDetaching, print the arrays it returns, and confirm that the other members kept theirjoined_on. - Register the morph map with
Relation::enforceMorphMap()inAppServiceProvider::boot(), write a migration that converts the existingcommentable_typevalues to the aliases, and read both seeded comments’ parents in Tinker.
Twelve questions follow, two for each idea of this page. One you miss comes back once before the session ends, and each choice carries its own reason:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): which table holds the foreign key of a one-to-many relationship, and how does Eloquent guess the column’s name?
The child table holds it, because each child row points at exactly one parent while a parent may have any number of
children: every batches row stores the centre_id of its centre, and the centres table stores nothing about
batches. Both sides read that one column. hasMany on the parent guesses it from the parent model’s name in snake
case plus _id, so Centre::batches() reads batches.centre_id. belongsTo on the child guesses it from the
relationship method’s name plus _id, so Instalment::student() reads student_id. A second argument replaces
either guess. That is why a belongsTo method is named after its parent: a method called learner() looks for
learner_id, and returns no parent at all unless strict mode turns the missing column into an exception.
Both longer questions start where this page’s runs stop: a report that needs a total across a pivot, and a morph map added to a table that already has rows. Decide what you would say first, then compare it with the first step suggested for each:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
The office wants a dashboard of the batches that have at least five students with an overdue instalment, with the number of those students and the overdue total of each batch. It must stay at one query however many batches there are. Write the query and explain each part.
-
Mid to senior
A team adds Relation::enforceMorphMap() to an application whose comments table already holds two million rows with class names in commentable_type. Minutes after the deploy, comment pages start failing. Explain what happened, how you would recover, and the rollout you would have planned instead.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which table holds the foreign key of a one-to-many relationship, and how is its name guessed?
The child table: every batches row stores its centre_id. hasMany on the parent guesses the column from the parent model's name in snake case plus _id, belongsTo on the child from the relationship method's name; both accept the name when you give it.
-
What does a many-to-many relationship need, and how can the pivot row carry facts?
A pivot table with one row per pair. withPivot, withTimestamps and as() decide what the pivot row gives back, and using() turns it into a pivot model with relationships of its own.
-
How do attach, sync, syncWithoutDetaching and toggle change a many-to-many pivot?
attach and detach add or remove single rows; sync makes its list the whole membership, removing the rest; syncWithoutDetaching only adds; toggle flips every id it is given.
-
How do you read payments two tables away, or one payment out of many, without loading them all?
hasManyThrough reads rows two tables away in one query, and latestOfMany or ofMany picks one related row in SQL instead of in PHP.
-
How do you filter or count by related rows without loading them?
has, whereHas and whereRelation put the condition into SQL, and the with aggregates such as withCount and withSum put the count or the total there, so PHP gets one number per row.
-
Why pin a polymorphic relation with an enforced morph map before it stores any rows?
The relation stores a type beside the id; a morph map stores a short alias there instead of the class name. On a table that already has rows, the enforced map and the conversion of the old type values must reach production together.
Key takeaways
- A one-to-many relationship is one foreign key on the child table, read by
hasManyfrom the parent and bybelongsTofrom the child, and both methods guess the key’s name unless you give it. - A many-to-many relationship keeps each pair in a pivot table, and
withPivot,withTimestamps,as()andusing()decide what the pivot row gives back, up to a pivot model with relationships of its own. attachanddetachchange single rows,syncmakes its list the whole membership,syncWithoutDetachingonly adds, andtoggleflips every id it is given.hasManyThroughreads rows two tables away in one query, andlatestOfManyandofManypick one related row in SQL instead of in PHP.has,whereHas,whereRelationand thewithaggregates put the condition or the count into SQL, which is how the recorded session got six lists from six queries.- A polymorphic relation stores a type beside the id; a morph map stores an alias there, and adding an enforced map to a table with rows means converting those rows before the map serves a request.
Terms in this lesson
- existence query
- A query that keeps only the parent rows that have, or lack, matching related rows. Eloquent writes it with has, doesntHave, whereHas or whereRelation as a subquery inside the parent's one statement.
- has-many-through relationship
- A relationship that reaches rows two tables away through an intermediate model: Student::payments() joins payments to instalments and keeps the rows whose instalment belongs to the student.
- many-to-many relationship
- A link where rows on both sides may have many partners, such as students and batches. Each pair is one row of a pivot table, and Eloquent reads it with belongsToMany on both models.
- morph map
- A list of short aliases for the models of polymorphic relationships, set with Relation::morphMap() or Relation::enforceMorphMap(), so type columns store an alias such as notice instead of a class name.
- one-of-many relationship
- A has-one relationship that picks a single row out of many related rows in SQL, such as the latest payment with latestOfMany() or the largest with ofMany('amount_paise', 'max').
- one-to-many relationship
- A link where one parent row has any number of child rows, each child holding the parent's id in a foreign key column. In Eloquent the parent declares hasMany and the child belongsTo, both over that one column.
- pivot model
- A class that extends Eloquent's Pivot and is named with using() on a belongsToMany relationship, so each pivot row becomes an object with methods and relationships of its own.
- pivot table
- The table that stores one row per pair of a many-to-many relationship: both foreign keys, plus any facts about the pair, such as the day a student joined a batch.
- polymorphic relationship
- A relationship in which a child row can belong to parents of different models. The child stores the parent's id and a type column that names the parent's model, such as commentable_id and commentable_type.
References
- Eloquent: Relationships (Laravel (laravel.com))
- Database: Migrations (Laravel (laravel.com))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress