Eloquent Relationships

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The relationships are those of the Bookshelf application, built over the previous pages, and the examples run on PostgreSQL 17.11 from a script that boots the application. Relationship methods return query builders, so everything on Eloquent Models applies to them as well.

The Bookshelf schema

erDiagram AUTHORS ||--o{ BOOKS : "hasMany / belongsTo" BOOKS ||--o{ REVIEWS : "hasMany, latestOfMany, ofMany" USERS ||--o{ REVIEWS : writes USERS ||--o{ LOANS : borrows BOOKS ||--o{ LOANS : "is lent in" BOOKS }o--o{ TAGS : "belongsToMany via BOOK_TAG" BOOKS ||--o{ IMAGES : "morphMany (imageable)" AUTHORS ||--o| IMAGES : "morphOne portrait" AUTHORS { bigint id PK string name } BOOKS { bigint id PK bigint author_id FK string title string isbn UK smallint published_year json meta } REVIEWS { bigint id PK bigint book_id FK bigint user_id FK tinyint rating timestamp deleted_at } LOANS { char ulid PK bigint book_id FK bigint user_id FK string status datetime due_at } TAGS { bigint id PK string name UK } BOOK_TAG { bigint book_id PK bigint tag_id PK string added_by } IMAGES { bigint id PK string imageable_type bigint imageable_id string path } USERS { bigint id PK string email UK }

Defining relationships

Each relationship is a method returning a relation object; Eloquent infers foreign keys from the names (author_id for belongsTo(Author::class), book_id for hasMany(Review::class)):

<?php
// app/Models/Author.php
namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphOne;

#[Fillable(['name'])]
class Author extends Model
{
    /** @use HasFactory<\Database\Factories\AuthorFactory> */
    use HasFactory;

    public function books(): HasMany                           // one-to-many
    {
        return $this->hasMany(Book::class);
    }

    public function reviews(): HasManyThrough                  // authors -> books -> reviews
    {
        return $this->hasManyThrough(Review::class, Book::class);
    }

    public function portrait(): MorphOne                       // one image, polymorphic
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}
<?php
// app/Models/Book.php (the relationship methods)
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Book extends Model
{
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }

    public function reviews(): HasMany
    {
        return $this->hasMany(Review::class);
    }

    public function latestReview(): HasOne                     // has-one-of-many
    {
        return $this->hasOne(Review::class)->latestOfMany();
    }

    public function bestReview(): HasOne
    {
        return $this->hasOne(Review::class)->ofMany('rating', 'max');
    }

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

    public function loans(): HasMany
    {
        return $this->hasMany(Loan::class);
    }

    public function images(): MorphMany
    {
        return $this->morphMany(Image::class, 'imageable');
    }
}
<?php
// app/Models/Image.php and the morph map in AppServiceProvider::boot()
namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;

#[Fillable(['path', 'alt'])]
class Image extends Model
{
    public function imageable(): MorphTo                       // a Book or an Author
    {
        return $this->morphTo();
    }
}

Relation::enforceMorphMap([   // store 'book', not 'App\Models\Book'
    'book' => \App\Models\Book::class,
    'author' => \App\Models\Author::class,
    'user' => \App\Models\User::class,
]);
  • $book->author (a dynamic property) runs the query once and caches the result on the model; $book->author() returns the relation to keep building ($author->books()->where(...)).

  • latestOfMany() and ofMany('rating', 'max') turn a one-to-many into a single related row selected by a subquery, so "latest review" needs no orderBy()->first() per book.

  • hasManyThrough() reaches reviews of an author’s books through books.author_id and reviews.book_id; hasOneThrough() is the single-row form.

  • Polymorphic relations store the parent’s type and ID (imageable_type, imageable_id, created by $table->morphs('imageable')). Without a morph map the type column holds the class name, which breaks every existing row when a model is renamed or moved; enforceMorphMap() stores short aliases and throws if a model without one is used. morphToMany/morphedByMany give polymorphic many-to-many (tags on books and authors).

  • $touches = ['book'] on Review updates the book’s updated_at whenever a review is saved, useful for cache keys.

Dynamic relationships

Model::resolveRelationUsing() adds a relationship at runtime, from a service provider’s boot() method. It is meant for packages that need to attach a relation to a model they do not own; in an application, a method on the model is clearer. Once registered, the relation works everywhere a method would: as a dynamic property, in has() and whereHas(), and in with():

<?php
// a package's service provider, boot()
use App\Models\{Book, Loan, User};

Book::resolveRelationUsing('overdueLoans', fn (Book $book) => $book->hasMany(Loan::class, 'book_id')
    ->where('status', 'active')->where('due_at', '<', now()));

echo 'dynamic: ', Book::has('overdueLoans')->pluck('title')->join(', '), '; eager: ',
    Book::with('overdueLoans')->find($kindred->id)->overdueLoans->count(), PHP_EOL;

Loan::resolveRelationUsing('borrower', fn (Loan $loan) => $loan->belongsTo(User::class));
echo 'guessed key: ', (new Loan)->borrower()->getForeignKeyName(), PHP_EOL;
Loan::resolveRelationUsing('borrower', fn (Loan $loan) => $loan->belongsTo(User::class, 'user_id'));
echo 'explicit key: ', (new Loan)->borrower()->getForeignKeyName(), PHP_EOL;
dynamic: Kindred; eager: 1
guessed key: {closure:/app/db/relationships.php:41}_id
explicit key: user_id

The documentation’s rule, "always provide explicit key name arguments", has a concrete reason. belongsTo() without a foreign key guesses it from the name of the method that called it, read from the backtrace. Inside a closure that name is the closure’s, so the guessed column was {closure:...}_id on PHP 8.5 (and {closure}_id on PHP 8.3, whose closures have no file and line in their name), and every query on the relation would fail. hasMany() guesses from the parent model instead (book_id), but passing the key costs nothing and survives a rename. A registration made later with the same name replaces the earlier one.

<?php
use App\Models\{Author, Book, Image, Review, Tag, User};

$ben = User::factory()->create(['name' => 'Ben Borrower']);
$kindred->reviews()->create(['user_id' => $ben->id, 'rating' => 3, 'body' => 'Harrowing.']);
$sower->reviews()->create(['user_id' => $ada->id, 'rating' => 4, 'body' => 'Prescient.']);
$butler->portrait()->create(['path' => 'portraits/butler.jpg', 'alt' => 'Octavia E. Butler']);
$kindred->images()->createMany([['path' => 'covers/kindred-1979.jpg'], ['path' => 'covers/kindred-2003.jpg']]);

$dystopia = Tag::firstOrCreate(['name' => 'dystopia']);
$sower->tags()->attach($dystopia, ['added_by' => 'ada']);
$changes = $sower->tags()->sync([$dystopia->id => ['added_by' => 'ada'], Tag::where('name', 'science fiction')->value('id')]);
echo 'sync: ', json_encode($changes), PHP_EOL;
echo 'toggle: ', json_encode($sower->tags()->toggle([$dystopia->id])), PHP_EOL;
$sower->tags()->attach($dystopia, ['added_by' => 'ben']);
echo 'pivot: ', $sower->tags()->where('name', 'dystopia')->first()->pivot->added_by, PHP_EOL;
sync: {"attached":[],"detached":[],"updated":[3]}
toggle: {"attached":[],"detached":[3]}
pivot: ben

create() on a hasMany or morphMany fills the foreign key (and type) itself. sync() makes the pivot rows match the list exactly and reports what changed: "science fiction" was already attached by the seeder, and passing pivot attributes for dystopia counted as an update. syncWithoutDetaching() only adds, toggle() flips, detach() removes, and updateExistingPivot() changes pivot columns. attach() does not check for duplicates: the composite primary key on book_tag is what stops a second attach() of the same tag, so pivot tables need one.

Custom pivot models

A pivot row is an Illuminate\Database\Eloquent\Relations\Pivot model. using() replaces it with a class of its own, which can hold methods, casts and model events for the pivot table. Bookshelf’s BookTag records who added a tag when the caller does not say. It needs no $timestamps = false: a pivot model turns timestamps on only when the attributes it is built from include created_at, which book_tag has no column for:

<?php
// app/Models/BookTag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class BookTag extends Pivot
{
    protected static function booted(): void
    {
        static::creating(function (BookTag $tagging) {
            $tagging->added_by ??= auth()->user()?->name ?? 'system';
        });
        static::deleted(fn (BookTag $tagging) => logger()->info('untagged', $tagging->only('book_id', 'tag_id')));
    }

    public function addedBySeeder(): bool
    {
        return $this->added_by === 'seeder';
    }
}

// app/Models/Book.php
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->using(BookTag::class)->withPivot('added_by');
}
<?php
$tag = $kindred->tags->first();
echo 'pivot class: ', get_class($tag->pivot), '; seeder? ', var_export($tag->pivot->addedBySeeder(), true), PHP_EOL;
auth()->setUser($ada);
$kindred->tags()->attach($dystopia);
echo 'attach as Ada: ', $kindred->tags()->where('name', 'dystopia')->first()->pivot->added_by, PHP_EOL;
auth()->forgetUser();
$sower->tags()->sync([$dystopia->id]);
echo 'sync as guest: ', $sower->tags()->where('name', 'dystopia')->first()->pivot->added_by, PHP_EOL;
$kindred->tags()->detach($dystopia);
echo 'detach logged: ', implode(' | ', $log), PHP_EOL;              // messages captured with Log::listen()
pivot class: App\Models\BookTag; seeder? true
attach as Ada: Ada Reader
sync as guest: system
detach logged: untagged {"book_id":2,"tag_id":1} | untagged {"book_id":1,"tag_id":6}

The pivot events are the reason to reach for a custom pivot: without using(), attach(), sync() and detach() write the table with bulk queries and no model is created, so no event fires. With it, attach() saves one BookTag per row and detach() loads the matching rows and deletes each one, which is why the log shows the science fiction row that sync() removed from Parable of the Sower as well as the explicit detach(). The price is a query per pivot row instead of one per call, so a large sync() is slower. Three rules from the documentation: a pivot model whose table has an auto-incrementing id needs #[Table(incrementing: true)] (on Laravel 13; the $incrementing = true property before), pivot models cannot use SoftDeletes, and a polymorphic many-to-many pivot extends MorphPivot instead. ->as('tagging') renames the pivot attribute when another name reads better.

Reading relationships

<?php
use App\Models\{Author, Book, Image};

echo 'latestReview: ', $kindred->latestReview->body, '; bestReview: ', $kindred->bestReview->rating, PHP_EOL;
echo 'through: ', $butler->reviews()->count(), ' reviews of Butler books; Le Guin: ', $leGuin->reviews()->count(), PHP_EOL;
$img = Image::where('path', 'portraits/butler.jpg')->first();
echo 'morph: ', $img->imageable_type, ' #', $img->imageable_id, ' -> ', get_class($img->imageable), ' ', $img->imageable->name, PHP_EOL;

echo 'has(reviews, >=2): ', Book::has('reviews', '>=', 2)->pluck('title')->join(', '), PHP_EOL;
echo 'whereHas(rating 5): ', Book::whereHas('reviews', fn ($q) => $q->where('rating', 5))->pluck('title')->join(', '), PHP_EOL;
echo 'whereRelation: ', Book::whereRelation('tags', 'name', 'classic')->orderBy('title')->pluck('title')->join(', '), PHP_EOL;
echo 'doesntHave: ', Book::doesntHave('reviews')->orderBy('title')->pluck('title')->join(', '), PHP_EOL;
$withAgg = Author::withCount('books')->withAvg('reviews', 'rating')->orderBy('name')->get();
foreach ($withAgg as $a) { echo "  {$a->name}: {$a->books_count} books, avg rating ", $a->reviews_avg_rating === null ? 'n/a' : round($a->reviews_avg_rating, 2), PHP_EOL; }
latestReview: Harrowing.; bestReview: 5
through: 3 reviews of Butler books; Le Guin: 0
morph: author #1 -> App\Models\Author Octavia E. Butler
has(reviews, >=2): Kindred
whereHas(rating 5): Kindred
whereRelation: Kindred, The Dispossessed, The Lathe of Heaven
doesntHave: The Dispossessed, The Lathe of Heaven
  Octavia E. Butler: 2 books, avg rating 4
  Ursula K. Le Guin: 2 books, avg rating n/a

The morph map shows in the data: imageable_type is author, not App\Models\Author, and Eloquent resolved it back to the class. withCount('books') and withAvg('reviews', 'rating') add books_count and reviews_avg_rating columns through subqueries, in the same query as the authors; withSum, withMin, withMax and withExists follow the same naming. The average for Butler (5, 3 and 4) is 4; Le Guin has no reviews, so the average is null, not 0. whereHas() with a closure constrains the related rows; whereRelation('tags', 'name', 'classic') is its short form, and whereMorphRelation() does the same across polymorphic types.

Querying polymorphic relations

whereHas() cannot follow a morphTo, because the related table depends on each row’s type. whereHasMorph() takes the relation, the types to consider and a closure, and builds one exists subquery per type; the closure’s second argument says which type it is constraining, so each table can be queried by its own columns. whereDoesntHaveMorph() is the negation, and whereMorphedTo() matches the children of one given parent:

<?php
use App\Models\{Author, Book, Image};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;

DB::listen(function () use (&$queries) { $queries++; });
echo 'whereHasMorph(Butler): ', Image::whereHasMorph('imageable', [Book::class, Author::class],
    fn (Builder $query, string $type) => $type === Book::class
        ? $query->whereRelation('author', 'name', 'Octavia E. Butler')
        : $query->where('name', 'Octavia E. Butler'))->orderBy('path')->pluck('path')->join(', '), PHP_EOL;
echo 'whereDoesntHaveMorph(Book by Butler): ', Image::whereDoesntHaveMorph('imageable', Book::class,
    fn (Builder $query) => $query->where('author_id', $butler->id))->orderBy('path')->pluck('path')->join(', '), PHP_EOL;
$queries = 0;
echo 'wildcard: ', Image::whereHasMorph('imageable', '*', fn (Builder $query, string $type) => $query->where('id', '>', 0))->count(), ' images, ', $queries, ' queries', PHP_EOL;
echo 'whereMorphedTo: ', Image::whereMorphedTo('imageable', $kindred)->pluck('path')->join(', '), PHP_EOL;
DB::enableQueryLog();
Image::whereHasMorph('imageable', [Book::class], fn ($q) => $q->where('published_year', '<', 1980))->get();
echo 'sql: ', DB::getQueryLog()[0]['query'], PHP_EOL;
whereHasMorph(Butler): covers/kindred-1979.jpg, portraits/butler.jpg
whereDoesntHaveMorph(Book by Butler): covers/dispossessed.jpg
wildcard: 3 images, 2 queries
whereMorphedTo: covers/kindred-1979.jpg
sql: select * from "images" where (("images"."imageable_type" = ? and exists (select * from "books" where "images"."imageable_id" = "books"."id" and "published_year" < ?)))

The images were a cover of Kindred, a cover of The Dispossessed and Butler’s portrait. The first query found both images that belong to Butler, through her book and directly, because the closure used author.name for books and name for authors. The generated SQL pairs the type test with the exists subquery; with the morph map, the bound type is the alias book, not the class name. '*' instead of a list of types first reads the distinct imageable_type values from the table, which is the second query, and then constrains each type found. whereDoesntHaveMorph() with one type keeps only images of that type whose parent fails the closure, so Butler’s portrait is not in its result.

Eager loading

Reading a relationship inside a loop runs one query per model, the N+1 problem. Counting queries with DB::listen() over the four books:

<?php
use App\Models\Book;

$queries = 0;
foreach (Book::all() as $b) { $b->author->name; }
echo 'lazy author per book: ', $queries, ' queries', PHP_EOL;
$queries = 0;
foreach (Book::with(['author', 'reviews' => fn ($q) => $q->where('rating', '>=', 4)])->get() as $b) { $b->author->name; $b->reviews->count(); }
echo 'with(author, reviews>=4): ', $queries, ' queries', PHP_EOL;
$books = Book::all(); $queries = 0;
$books->loadMissing('author'); $books->loadMissing('author');
echo 'loadMissing twice: ', $queries, ' query', PHP_EOL;
lazy author per book: 5 queries
with(author, reviews>=4): 3 queries
loadMissing twice: 1 query

One query for the books plus one per book makes 5; with() loads each relationship in one where ... in (...) query whatever the number of books, so two relationships cost 3 queries in total. The closure constrains the eager load (only reviews rated 4 or more are loaded into $book->reviews, which then no longer means "all reviews"). load() eager-loads on models already fetched, loadMissing() only what is not loaded yet, with('author:id,name') selects specific columns (the key must be included), and $with = ['author'] on the model eager-loads by default. Detecting and preventing N+1 in a whole application is on Query Performance and N+1.

Associating and touching

<?php
use App\Models\Review;

$review = Review::where('body', 'Harrowing.')->first();
$before = $sower->fresh()->updated_at;
sleep(1);
$review->book()->associate($sower)->save();
echo 'associate: review now on ', $review->book->title, '; touched parent: ', var_export($sower->fresh()->updated_at->gt($before), true), PHP_EOL;
// associate: review now on Parable of the Sower; touched parent: true

associate() sets the foreign key (and the cached relation) on the child, dissociate() sets it to null; nothing is written until save(). Because Review lists book in $touches, saving it updated Parable of the Sower's updated_at. On a hasMany, chaperone() sets the inverse relation on each loaded child ($review->book without a query), and withAttributes([...]) on a scoped relationship both filters by and fills those attributes when creating.

Where older books and tutorials are outdated

  • orderBy('created_at', 'desc')->first() per parent for "latest child" is latestOfMany() (Laravel 8), which eager-loads in one query.

  • Class names in *_type columns (the default in Stauffer ch. 5) make renames painful; enforceMorphMap() (Laravel 8) stores stable aliases.

  • withCount() only is joined by withSum, withAvg, withMin, withMax and withExists (Laravel 8).

  • whereHas('tags', fn ($q) => $q->where('name', ...)) for every lookup has the shorter whereRelation() (Laravel 8).

  • Manually setting $review->setRelation('book', $book) in loops is chaperone() (Laravel 11).