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
Source: Laravel — Defining Relationships
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,
]);
Source: Laravel — One to Many, Has One of Many and Custom Polymorphic Types
-
$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()andofMany('rating', 'max')turn a one-to-many into a single related row selected by a subquery, so "latest review" needs noorderBy()->first()per book. -
hasManyThrough()reaches reviews of an author’s books throughbooks.author_idandreviews.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/morphedByManygive polymorphic many-to-many (tags on books and authors). -
$touches = ['book']onReviewupdates the book’supdated_atwhenever 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
Source: Laravel — Dynamic Relationships
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.
Inserting and syncing related models
<?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}
Source: Laravel — Defining Custom Intermediate Table Models and Custom Pivot Models and Incrementing IDs
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
Source: Laravel — Eager Loading and Constraining Eager Loads
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" islatestOfMany()(Laravel 8), which eager-loads in one query. -
Class names in
*_typecolumns (the default in Stauffer ch. 5) make renames painful;enforceMorphMap()(Laravel 8) stores stable aliases. -
withCount()only is joined bywithSum,withAvg,withMin,withMaxandwithExists(Laravel 8). -
whereHas('tags', fn ($q) => $q->where('name', ...))for every lookup has the shorterwhereRelation()(Laravel 8). -
Manually setting
$review->setRelation('book', $book)in loops ischaperone()(Laravel 11).