Eloquent relationships, casts and query scopes
hasMany, belongsTo, many-to-many and polymorphic relations, eager loading with constraints, attribute casts and local scopes that cut duplication.
Declaring relations
class Author extends Model
{
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
class Book extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}- Laravel infers the foreign key from the method name:
author()looks forauthor_id. Pass explicit keys when the schema disagrees. - The inverse side is not set automatically.
$book->author()->associate($author)writes the key; assigning$book->author_iddirectly also works but the loaded relation stays stale. morphManyneedscommentable_idandcommentable_type; the type stores the model class name, so renaming a class is a data migration.
⚠️
A missing relation returns
null, not an error, so $book->author->name fails only when the data is inconsistent. Add a foreign key constraint in the migration so the database rejects the row instead of your template.Eager loading and the N+1 problem
// N+1: one query for books, then one per author
$books = Book::all();
// fixed: two queries, regardless of row count
$books = Book::with('author')->get();
// constrained eager load - only published reviews, ordered
$books = Book::with(['author:id,name', 'reviews' => function ($q) {
$q->where('approved', true)->latest()->limit(3);
}])
->withCount('reviews')
->withAvg('reviews', 'rating')
->paginate(20);
// default eager loading for a whole model
class Book extends Model
{
protected $with = []; // keep empty; prefer explicit with()
protected $withCount = [];
}| Method | Queries | Use |
|---|---|---|
with() | 1 + 1 per relation | The normal fix for a list screen |
load() | Runs after the parent query | Loading on a single already-fetched model |
withCount() | Adds a subquery | Showing counts without loading rows |
withWhereHas() | Filters parents by a relation | Replaces a manual join |
lazy() / lazyById() | Chunked cursors | Exporting large tables |
Detect N+1 before production: enable Model::preventLazyLoading(! app()->isProduction()) in a service provider, and loosen it only with ->withoutLazyLoading() where you truly have one row.
Casts, mutators and scopes
class Book extends Model
{
protected function casts(): array
{
return [
'published_at' => 'immutable_datetime',
'price_cents' => 'integer',
'meta' => 'array',
'status' => BookStatus::class,
'isbn' => 'encrypted',
];
}
protected function title(): Attribute
{
return Attribute::make(
get: fn ($value) => ucwords($value),
set: fn ($value) => trim($value),
);
}
public function scopePublished(Builder $query): void
{
$query->whereNotNull('published_at')->where('published_at', '<=', now());
}
public function scopeByAuthor(Builder $query, Author $author): void
{
$query->whereBelongsTo($author);
}
}
$books = Book::published()->byAuthor($author)->get();- Casts run on both read and write, so heavy mutators apply in bulk operations too and slow large
insert()calls - considerDB::table()for imports. - Enum casts throw when the database holds a value outside the enum; a legacy column needs a nullable cast or a data migration first.
- A scope with no arguments can be called as
Book::published(); with arguments you must use the query builder chain, not a static call.
FAQ
Why is my relation query hitting the database repeatedly?
Almost always a lazy load inside a loop. Add
with() at the point of the outer query, and enable lazy-load prevention in development so the mistake fails loudly instead of quietly costing queries.Should I use soft deletes?
Only when you need to restore data or keep historical references. Soft deletes complicate every unique index, every query and every relation, because you must remember
withTrashed() exactly where history is required.Related
Controllers, Blade views and Eloquent models Caching, performance and debugging
Last refreshed 2026-09-18.