Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

app/Models/Centre.php: the parent side, hasMany PHP · pathshala-graph/app/Models/Centre.php
<?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);
    }
}
app/Models/Instalment.php: belongsTo upwards, hasMany downwards PHP · pathshala-graph/app/Models/Instalment.php
<?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.

Interactive · step by step Pathshala's tables, one relationship at a time Step through the graph the tour session queries. Each step adds one key or table and the method that reads it.
Pathshala's tables, one relationship at a time, step 7 of 7. Showing Centre, Batch, enrolments, FeePlan, Student, Guardian, Instalment, Payment. In focus: Student, Payment.hasManybelongsTohasManyhasManythroughCentrecentresBatchcentre_idenrolmentspivot tableFeePlanfee_plan_idStudentstudentsGuardianguardian_studentInstalmentstudent_idPaymentinstalment_id

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

Six office questions answered through relationships, with query counts Python · tour_session.py
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:

Guessed keys, the property and the method, and a renamed belongsTo Python · key_guess_session.py
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

  1. Question 1 of 2 Pathshala Andheri runs two batches. Which column records that fact?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: centre_id on each batches row

    In a one-to-many relationship the foreign key sits on the child table. hasMany on Centre and belongsTo on Batch are two methods over the same batches.centre_id column.

  2. Question 2 of 2 In the key-guess session, $centre->batches was read twice in a row. How many queries did the two reads cost, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: One, because the property keeps the loaded collection on the model

    A dynamic relationship property runs its query on first access and keeps the collection on the model. The method form returns a query builder, so you can add conditions before it runs.

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:

app/Models/Batch.php: belongsToMany through the enrolments table PHP · pathshala-graph/app/Models/Batch.php
<?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);
    }
}
app/Models/Enrolment.php: the pivot row as a model PHP · pathshala-graph/app/Models/Enrolment.php
<?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’s created_at and updated_at filled, as Eloquent does for a model’s own timestamps.
  • as('enrolment') renames that object from pivot to enrolment, 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 extends Pivot and 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

  1. Question 1 of 2 Student::guardians() calls belongsToMany(Guardian::class) with no table name. Which table does Eloquent read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: guardian_student

    The default pivot table name is both model names, singular, in alphabetical order. Any other name, such as enrolments, is given as the second argument of belongsToMany.

  2. Question 2 of 2 Which call on Batch::students() lets the tour write $student->enrolment->joined_on instead of $student->pivot->joined_on?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: as('enrolment')

    withPivot chooses the extra columns, using chooses the pivot class, withTimestamps maintains the pivot's timestamps and as chooses the attribute name. Batch::students() uses all four.

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:

attach, detach, sync, syncWithoutDetaching and toggle on one batch Python · pivot_writes_session.py
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.

Interactive · stage by stage Maths Morning's members after each pivot write Step through the five calls of the recorded run and watch which roll numbers each one adds or removes.
Maths Morning's members after each pivot write, step 6 of 6. Showing Seeded batch, attach(P-005) (P-001 to P-005), detach(P-003) (P-001 P-002 P-004 P-005), sync(three ids) (P-001 P-002 P-006), syncWithoutDetaching (P-001 P-002 P-003 P-006), toggle(P-001 P-005) (P-002 P-003 P-005 P-006). In focus: toggle(P-001 P-005).Seeded batchP-001 P-002 P-003 P-004attach(P-005)P-001 to P-005detach(P-003)P-001 P-002 P-004 P-005sync(three ids)P-001 P-002 P-006syncWithoutDetachingP-001 P-002 P-003 P-006toggle(P-001 P-005)P-002 P-003 P-005 P-006

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Adding without removing. syncWithoutDetaching(P-003) attached P-003 again and detached nobody, so the three members that sync() left all stayed.
  6. 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

  1. Question 1 of 2 Maths Morning holds P-001, P-002, P-004 and P-005. sync() is given P-001, P-002 with a new fee plan, and P-006. What does it return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: attached P-006, detached P-004 and P-005, updated P-002

    sync makes its list the whole membership and reports what it did in three arrays: attached, detached and updated. The ids missing from the list lose their pivot rows and the data on them.

  2. Question 2 of 2 attach(P-005) ran twice on Maths Morning. What stopped the second call in the recorded run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The unique index on batch_id and student_id, which raised UniqueConstraintViolationException

    attach always inserts. A unique index on the pair is what keeps a student from being enrolled twice in one batch, and the database raises the error.

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.

app/Models/Student.php: two pivots, a through relation and two one-of-many relations PHP · pathshala-graph/app/Models/Student.php
<?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

  1. Question 1 of 2 The payments table has no student_id column. How does Student::payments() find a student's payments?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It joins payments to instalments and keeps the rows whose instalment has the student's id

    hasManyThrough(Payment::class, Instalment::class) joins the final table to the intermediate one and filters on the intermediate table's student_id.

  2. Question 2 of 2 Aarav paid 2,500 rupees by card, then 1,000 by cash, then 1,500 by bank. Which payments do latestOfMany('paid_at') and ofMany('amount_paise', 'max') return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The 1,500 bank payment, and the 2,500 card payment

    latestOfMany keeps the row with the greatest value of its column and ofMany applies the aggregate it is given, max here. Both pick the row in SQL.

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:

has, whereHas, whereRelation and the with-aggregates, with their SQL Python · related_queries_session.py
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

  1. Question 1 of 2 What SQL did Student::whereRelation('instalments', 'status', 'overdue') send in the session?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A students query with an exists subquery on instalments, and overdue as a binding

    whereRelation and whereHas become an exists subquery in the where clause, so the database checks the related rows and returns only matching parents, once each.

  2. Question 2 of 2 Which attribute holds the result of Student::withSum('payments', 'amount_paise')?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: payments_sum_amount_paise

    withSum, withMin, withMax and withAvg name their attribute {relation}_{function}_{column}; an alias such as 'instalments as due_paise' replaces the whole name.

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.

app/Models/Comment.php: morphTo reads the type column PHP · pathshala-graph/app/Models/Comment.php
<?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:

commentable_type with and without Relation::enforceMorphMap() Python · morph_map_session.py
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.

Interactive · stage by stage Adding a morph map to a table that has rows Step through what commentable_type holds, and which reads work, at each step of the recorded run.
Adding a morph map to a table that has rows, step 5 of 5. Showing No morph map, A model renamed, enforceMorphMap(), Old rows read, Old rows moved. In focus: Old rows moved.No morph mapstores the class nameA model renamedthe stored class is goneenforceMorphMap()new rows store an aliasOld rows readrefusedOld rows movedevery row reads

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 With no morph map, what did the seeded comment on a notice store in commentable_type?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: App\Models\Notice

    A polymorphic relation writes two columns: the parent's id and its type. By default the type is the class name, which ties the stored data to the code.

  2. Question 2 of 2 Relation::enforceMorphMap() with the aliases homework and notice is switched on while comment 1 still stores App\Models\Homework. What happens when its parent is read?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails with ClassMorphViolationException until the row is converted to the alias

    enforceMorphMap both stores aliases and requires them. Rows written before it must be converted, which the documentation calls for when a map is added to an existing application.

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:

database/seeders/PathshalaGraphSeeder.php: the data every session seeds PHP · pathshala-graph/database/seeders/PathshalaGraphSeeder.php
<?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?']);
    }
}
  1. Run php artisan migrate --pretend to 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 run php artisan migrate and php artisan db:seed --class=PathshalaGraphSeeder.
  2. 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.
  3. 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.
  4. Add withCount(['students as overdue_children' => ...]) to the same query, with the overdue condition in the closure, and confirm with DB::enableQueryLog() that the list with its counts still costs 1 query.
  5. 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 their joined_on.
  6. Register the morph map with Relation::enforceMorphMap() in AppServiceProvider::boot(), write a migration that converts the existing commentable_type values 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.

  1. Question 1 of 12 An old batches table calls its centre column branch_id. Which definition of Batch::centre() reads it?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: return $this->belongsTo(Centre::class, 'branch_id');

    belongsTo guesses the key from the method name plus _id. When the column has another name, give it as the second argument rather than renaming the method to match the column.

  2. Question 2 of 12 A teammate renames Instalment::student() to payer(). The fee page now shows blank student names and no error. What explains it, and what would have caught it at once?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: payer() guesses payer_id, the column is missing, so the parent is NULL; preventAccessingMissingAttributes() would have thrown

    A belongsTo method's name is part of its key. Keep it named after the parent, give the key explicitly, and run development with strict mode so a missing column throws instead of reading as NULL.

  3. Question 3 of 12 Why does Pathshala give the enrolments pivot its own Enrolment class with using(Enrolment::class)?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: So each pivot row is a model with methods and relationships, such as feePlan()

    A custom pivot model extends Pivot. Use one when the pair itself has behaviour: a relationship like feePlan(), a method, or casts on its columns.

  4. Question 4 of 12 The office wants to record which staff member enrolled each student in each batch. Where does that fact go, and what else must change?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: A new column on enrolments, named in withPivot() of both relationships and given in attach()

    Facts about the pair go on the pivot table. Add the column in a migration, list it in withPivot() on both sides of the relationship, and give it in the attributes of attach() or sync().

  5. Question 5 of 12 An admin page shows 20 of a batch's 60 students at a time and saves with sync() of the ids shown on that page. What happens when someone saves the second page?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: The 40 students on the other pages are detached, and their joined_on rows are deleted

    Give sync only a list that is always complete. Save a page's changes with attach and detach, or with syncWithoutDetaching for additions.

  6. Question 6 of 12 A batch has P-002, P-003 and P-006. What does toggle() with P-003 and P-004 leave?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: P-002, P-004 and P-006

    toggle flips each id it is given and leaves the rest alone. In the recorded run it detached P-001 and attached P-005 in one call.

  7. Question 7 of 12 How many queries did the tour's withSum('payments', 'amount_paise') cost for all six students?

    Type a number.

    A hint

    Read the count printed under question 5 of the tour.

    Show the answer: Question 7 of 12

    Answer: 1 queries

    The sum is a subquery in the select list over the has-many-through join, so the six totals arrive with the students in one statement.

  8. Question 8 of 12 Question 7 of the tour read latestPayment inside a loop over 6 students and cost 7 queries. Read the same way, how many queries would 600 students cost?

    Type a number.

    A hint

    One query loads the students; count what each student adds.

    Show the answer: Question 8 of 12

    Answer: 601 queries

    The loop costs 1 query for the students plus 1 for each student's latest payment, so 600 students cost 601. Loading the relationship for every student at once is the subject of the eager loading lesson.

  9. Question 9 of 12 The office wants every batch with its number of students, in one query. Which call?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Batch::withCount('students')->get()

    Filters (has, whereHas, whereRelation) decide which parents come back; aggregates (withCount, withSum, withExists) add a value to each parent. Both run inside the parent's query.

  10. Question 10 of 12 Query A chains whereRelation('instalments', 'status', 'overdue') and whereRelation('instalments', 'amount_paise', '>', 200000). Query B is one whereHas('instalments') with both conditions in its closure. How can they differ?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: A can match a student whose overdue instalment is small and whose large one is paid; B needs one instalment meeting both

    When two conditions must hold for the same related row, put them in one whereHas closure. Separate whereRelation calls are separate existence checks.

  11. Question 11 of 12 Why store the alias notice rather than App\Models\Notice in commentable_type?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The stored data no longer depends on the class name, so renaming or moving the class strands no rows

    A morph map decouples the stored type from the application's class names. The map can later point an alias at a renamed class without touching a single row.

  12. Question 12 of 12 The enforced map lists homework and notice. A developer adds Batch::comments() as a morphMany and leaves the map alone. What happens the first time someone comments on a batch?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: ClassMorphViolationException naming App\Models\Batch, before the row is written

    With enforceMorphMap, adding a new polymorphic parent means adding its alias to the map in the same change. The exception names the class that is missing.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 hasMany from the parent and by belongsTo from 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() and using() decide what the pivot row gives back, up to a pivot model with relationships of its own.
  • attach and detach change single rows, sync makes its list the whole membership, syncWithoutDetaching only adds, and toggle flips every id it is given.
  • hasManyThrough reads rows two tables away in one query, and latestOfMany and ofMany pick one related row in SQL instead of in PHP.
  • has, whereHas, whereRelation and the with aggregates 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.