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 for author_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_id directly also works but the loaded relation stays stale.
  • morphMany needs commentable_id and commentable_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 = [];
}
MethodQueriesUse
with()1 + 1 per relationThe normal fix for a list screen
load()Runs after the parent queryLoading on a single already-fetched model
withCount()Adds a subqueryShowing counts without loading rows
withWhereHas()Filters parents by a relationReplaces a manual join
lazy() / lazyById()Chunked cursorsExporting 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 - consider DB::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.

Controllers, Blade views and Eloquent models Caching, performance and debugging

Last refreshed 2026-09-18.