Laravel Eloquent Relationships Deep Dive: The Queries Running Behind Your Code Will Surprise You

hasOne, hasMany, belongsToMany, hasManyThrough, morphTo — every relationship explained with the actual SQL it generates, the eager loading strategy that keeps queries under 5, and the relationship anti-patterns that look clean in PHP but destroy database performance at scale.


A dashboard page that loads in 200ms locally takes 4 seconds in production. Nobody changed the code between staging and production — the only difference is data volume. The culprit, found after turning on query logging: 847 queries for a single page render. The code reads cleanly — a loop over orders, printing each customer’s name. Nothing about foreach ($orders as $order) { echo $order->customer->name; } looks like it could generate 847 queries. It generates exactly one query per order, plus one to fetch the orders — because $order->customer is a relationship, and every access to a relationship that hasn’t been eager-loaded is a fresh trip to the database, invisible in the PHP and completely visible in the query log nobody was watching.

This is the gap this post closes: what SQL each Eloquent relationship type actually generates, not just how to declare it. hasOne and hasMany look similar in code and generate similar queries with a critical difference in what happens when you assume the wrong one. belongsToMany involves a pivot table most people can describe but few can predict the exact join for. hasManyThrough skips a table’s data but not its query. morphTo generates a fundamentally different query shape per polymorphic type, and that difference is exactly what causes it to break the N+1 solution that works for every other relationship. Every relationship here comes with the actual generated SQL, the eager-loading pattern that keeps a page under five total queries regardless of how many rows it displays, and the anti-patterns that pass code review clean and only show their cost once real data volume hits them.


hasOne / belongsTo — The Direction Nobody Checks

class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

class Profile extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
$user->profile;
// SELECT * FROM profiles WHERE profiles.user_id = ? LIMIT 1

$profile->user;
// SELECT * FROM users WHERE users.id = ? LIMIT 1

Both queries look almost identical, and that similarity is exactly what hides the actual difference: hasOne filters the related table by a foreign key pointing back to this model’s primary key. belongsTo filters the current table’s foreign key column against the related model’s primary key. The foreign key physically lives on the profiles table in both directions — hasOne and belongsTo are two different ways of querying across the same column, not two different schema shapes.

The mistake that’s invisible until it isn’t: declaring hasOne on a relationship that isn’t actually enforced as one-to-one at the database level. If profiles.user_id has no unique constraint, nothing stops two profile rows from existing for the same user. hasOne doesn’t error in that case — it silently returns whichever row the database happens to return first for that LIMIT 1, which is not guaranteed to be consistent across queries without an explicit ORDER BY. A user with two profile rows due to a bug elsewhere in the app can end up seeing different profile data on different page loads, and nothing in the Eloquent layer will ever tell you why.

// The constraint that actually enforces the relationship the PHP code assumes
Schema::table('profiles', function (Blueprint $table) {
    $table->unique('user_id');
});

If a relationship is declared hasOne in PHP, it needs a matching unique constraint in the schema. Otherwise the code is describing an intention, not an actual guarantee, and the database will happily let reality drift from what the model claims.


hasMany — Where the N+1 Problem Actually Lives

class Order extends Model
{
    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}
// The 847-query dashboard from the opening
foreach ($orders as $order) {
    echo $order->items->count();
}
// 1 query for $orders, then 1 additional query PER order for ->items
// 200 orders = 201 queries
// Eager loaded — the fix
$orders = Order::with('items')->get();

foreach ($orders as $order) {
    echo $order->items->count();
}
// SELECT * FROM orders
// SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 200)
// 2 queries, regardless of whether there are 20 orders or 20,000

with('items') doesn’t run 200 separate queries and merge them — it runs exactly one additional query with a single WHERE order_id IN (...) covering every order fetched in the first query, then matches each item back to its parent order in PHP memory. This is the entire mechanism behind eager loading, and understanding it explains why the query count stays flat as row count grows: the number of queries is fixed by the number of relationship accesses in the code, not by the number of rows returned.

The anti-pattern that survives code review: eager loading the relationship, then still triggering N+1 by accessing a nested relationship that wasn’t included in the same with() call.

// ❌ items is eager-loaded. item.product is not.
$orders = Order::with('items')->get();

foreach ($orders as $order) {
    foreach ($order->items as $item) {
        echo $item->product->name; // N+1, one query per item across every order
    }
}
// ✅ Nested eager loading, dot notation
$orders = Order::with('items.product')->get();

This is the single most common way a codebase that “already uses eager loading” still has an N+1 problem — the top-level relationship got the with() call, and a relationship accessed two levels deep didn’t, because it’s easy to add a new nested access to a loop months later without remembering to extend the original with() call to match.

Counting without loading: a common variant of the same mistake is eager-loading a full relationship just to display a count.

// ❌ Loads every item row into memory just to count them
$orders = Order::with('items')->get();
$orders->each(fn ($order) => $order->items->count());
// ✅ withCount — a single SUBQUERY-based aggregate, no item rows loaded at all
$orders = Order::withCount('items')->get();
$orders->each(fn ($order) => $order->items_count); // no additional query, no loaded rows

withCount generates a single query with a correlated subquery per counted relationship — SELECT orders.*, (SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id) as items_count FROM orders — which is both fewer queries than the eager-loaded version and dramatically less data transferred, since none of the actual order_items rows are ever fetched.


belongsToMany — The Pivot Table Query Nobody Reads Closely

class Post extends Model
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class); // pivot table: post_tag
    }
}
$post->tags;
// SELECT tags.*, post_tag.post_id as pivot_post_id, post_tag.tag_id as pivot_tag_id
// FROM tags
// INNER JOIN post_tag ON tags.id = post_tag.tag_id
// WHERE post_tag.post_id = ?

The generated query is a real INNER JOIN against the pivot table, and the pivot_* aliased columns are what populate the ->pivot property Eloquent attaches to each related model — that’s not magic, it’s just selected columns from the join, renamed to avoid colliding with the related model’s own column names.

Extra pivot columns need to be explicitly requested, or they silently don’t exist on the model.

// ❌ created_at on the pivot table exists in the schema. Eloquent doesn't know to select it.
$post->tags->first()->pivot->created_at; // null, even though the column has data

// ✅ withPivot — explicitly include extra pivot columns in the select
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->withPivot('created_at', 'added_by');
}

Without withPivot, Eloquent’s default belongsToMany query only selects the two foreign key columns needed to match the relationship — any other column on the pivot table exists in the database but is invisible to the model, silently, with no error. This is a common source of “the column has data in the database but the app shows null,” and the fix is a one-line addition to the relationship definition that’s easy to forget the first time a pivot table needs to carry more than just the two foreign keys.

Attaching, syncing, and the query each one actually runs:

$post->tags()->attach($tagId);
// INSERT INTO post_tag (post_id, tag_id) VALUES (?, ?)
// Adds a row. Does NOT check for duplicates unless the pivot table has a unique constraint.

$post->tags()->sync([$tagId1, $tagId2]);
// DELETE FROM post_tag WHERE post_id = ? AND tag_id NOT IN (?, ?)
// INSERT INTO post_tag (post_id, tag_id) VALUES (?, ?) -- for any not already present
// Replaces the full set — anything not in the array gets removed.

$post->tags()->syncWithoutDetaching([$tagId]);
// Only adds what's missing. Never removes existing pivot rows.

The mistake that causes real data loss: calling sync() when attach() (or syncWithoutDetaching()) was intended. sync() treats the array as the complete desired state of the relationship — anything currently attached but missing from the array gets deleted. A form that submits only the tags a user just added, passed straight into sync() instead of attach(), silently removes every previously attached tag not present in that specific request’s payload. This has shipped as a real bug more than once: a “add tag” button wired to sync() because it looked interchangeable with attach() in a quick read of the code.


hasManyThrough — Skips the Middle Table’s Data, Not Its Query

class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
        // Country -> Users -> Posts, without ever loading User models
    }
}
$country->posts;
// SELECT posts.*, users.country_id
// FROM posts
// INNER JOIN users ON users.id = posts.user_id
// WHERE users.country_id = ?

The name suggests the intermediate table is being bypassed — it’s not. The generated query still joins through users; what hasManyThrough actually skips is hydrating User models into PHP memory. This is a meaningful difference from manually looping through users to collect their posts (which would load every intermediate User model as a real object), but it’s still a real join in the SQL, still touching every row in users that matches the country_id filter, and still needs the same indexing consideration any join does.

// The index that makes this query worth using instead of two round trips
Schema::table('users', function (Blueprint $table) {
    $table->index('country_id');
});

Schema::table('posts', function (Blueprint $table) {
    $table->index('user_id');
});

Without both indexes, hasManyThrough on a large users or posts table degrades to a join without index support on either side — exactly as expensive as a poorly-indexed manual join would be. The convenience is in the PHP, not a free pass on the database considerations a join always carries.


morphTo — Why It Breaks the N+1 Fix That Works Everywhere Else

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo(); // could be a Post, a Video, a Photo — any model
    }
}
// The comments table has commentable_id AND commentable_type columns
$comment->commentable;
// If commentable_type is 'App\Models\Post':
//   SELECT * FROM posts WHERE id = ?
// If commentable_type is 'App\Models\Video':
//   SELECT * FROM videos WHERE id = ?

Every other relationship type generates the same query shape regardless of which specific row is being fetched. morphTo doesn’t — the actual table queried depends on the _type column’s value for that specific row, which means a collection of comments attached to a mix of posts, videos, and photos can’t be resolved with a single eager-loading query the way Comment::with('post') would work if every comment only ever attached to a post.

// What eager loading morphTo actually generates
$comments = Comment::with('commentable')->get();

// Eloquent groups the loaded comments by commentable_type first, THEN issues
// one query per distinct type present in the result set:
// SELECT * FROM posts WHERE id IN (...)   -- for every comment attached to a Post
// SELECT * FROM videos WHERE id IN (...)  -- for every comment attached to a Video
// SELECT * FROM photos WHERE id IN (...)  -- for every comment attached to a Photo

This is still N+1-safe in the sense that it doesn’t scale with row count — it scales with the number of distinct polymorphic types present, which is normally a small, fixed number (three or four commentable types, not three or four hundred). The mistake worth knowing about specifically: Comment::with('commentable')->get() on a comments table with a wide variety of commentable_type values still runs one query per distinct type, so a polymorphic relationship design with a large number of possible commentable types is inherently a worse fit for cheap eager loading than the same relationship modeled with a smaller number of types, or split into separate foreign keys entirely.

morphMap matters more than it looks like a cosmetic setting.

// Without a morph map: commentable_type stores the full class name
// "App\\Models\\Post" — brittle the moment a model gets renamed or namespaced

// AppServiceProvider::boot()
Relation::enforceMorphMap([
    'post' => Post::class,
    'video' => Video::class,
    'photo' => Photo::class,
]);

Without an explicit morph map, commentable_type stores the fully-qualified class name as a string — App\Models\Post. Renaming a model, or moving it to a different namespace during a refactor, silently breaks every existing polymorphic row referencing the old class string, because nothing updates historical data when a class gets renamed. enforceMorphMap stores a short, stable alias instead (post) that survives a class rename or namespace move untouched — this is worth setting up before a polymorphic relationship has any real data in it, because migrating existing commentable_type values from full class names to a morph map alias after the fact is a real data migration, not a config change.


Keeping a Real Page Under Five Queries

The discipline that keeps a data-heavy page’s query count flat, regardless of how many rows it renders, comes down to auditing every relationship access in a page’s render path and eager-loading exactly what’s touched — no more, no less.

// A dashboard order list — every relationship actually used in the view,
// eager-loaded in one call, nested where needed
$orders = Order::query()
    ->with([
        'customer:id,name,email',       // constrained columns — don't select more than needed
        'items.product:id,name,price',   // nested, matching what the view actually accesses
    ])
    ->withCount('items')                 // count without loading, where only a count is shown
    ->latest()
    ->paginate(20);
Query 1: SELECT * FROM orders ... LIMIT 20
Query 2: SELECT id, name, email FROM customers WHERE id IN (...)
Query 3: SELECT * FROM order_items WHERE order_id IN (...)
Query 4: SELECT id, name, price FROM products WHERE id IN (...)
Query 5: (the withCount subquery is folded into query 1, not a separate query)

Total: 4 queries. Flat at 20 rows, flat at 20,000.

Constraining eager-loaded columns with 'customer:id,name,email' isn’t just a minor optimization — on a customers table with a large text column (a bio, a notes field) that the dashboard view never renders, selecting every column on every eager load multiplies the actual data transferred by however many unused large columns exist, for every single page load, forever, silently, because nothing in the PHP output signals that extra data was fetched and immediately discarded.

The tool that catches what code review can’t: Laravel’s DB::listen() or a package like Laravel Debugbar in development, and something like Laravel Telescope for staging — not to eyeball a query count once during development, but as a standing check. A relationship access added six months after a page was last audited is invisible in a diff that only shows the new line, not the query count it silently reintroduces.

// A cheap guardrail worth adding to a test suite for pages that matter
it('renders the dashboard in a bounded number of queries', function () {
    DB::enableQueryLog();

    $this->actingAs($user)->get(route('dashboard'));

    expect(DB::getQueryLog())->toHaveCount(function ($count) {
        return $count <= 6;
    });
});

A query-count assertion in a test isn’t about hitting an exact number — it’s a tripwire that fails loudly the moment someone adds a new relationship access to a hot page without adding the matching eager load, catching the regression in CI instead of in a production query log six months later.


The One Rule

Every relationship type in Eloquent hides a real SQL query behind a PHP property access, and the convenience of that syntax is exactly what makes it easy to write code that reads as free and runs as expensive. hasOne without a unique constraint is a promise the schema doesn’t keep. hasMany without matching nested eager loads is N+1 wearing a with() call as camouflage. belongsToMany‘s sync() deletes what it isn’t told to keep. hasManyThrough still pays for the join it appears to skip. morphTo scales with type count, not row count, and needs a morph map before it has real data to protect. None of these are Eloquent bugs — they’re the predictable result of writing code against what a relationship looks like in PHP instead of what it actually runs against the database, and the fix for all of them is the same habit: read the query log before assuming the query.

Leave a Reply

Your email address will not be published. Required fields are marked *